Merge main into gpu-resident-effects

This commit is contained in:
Matej Kozlovský 2026-10-12 12:41:40 +02:00
commit f6497177b4
175 changed files with 14942 additions and 811 deletions

View File

@ -34,3 +34,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

View File

@ -58,7 +58,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:

75
Cargo.lock generated
View File

@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1159,6 +1159,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
@ -1167,7 +1168,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1179,14 +1180,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1202,7 +1203,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@ -1210,7 +1211,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1227,7 +1228,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1260,7 +1261,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@ -1290,7 +1291,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1307,7 +1308,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@ -1315,7 +1316,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1337,14 +1338,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1368,7 +1369,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1387,7 +1388,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1399,7 +1400,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1416,7 +1417,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1430,6 +1431,7 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-time",
"gif",
@ -1442,7 +1444,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-geom",
"image",
@ -1454,14 +1456,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1473,7 +1475,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1487,7 +1489,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1499,7 +1501,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1512,14 +1514,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1528,7 +1530,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1550,7 +1552,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1558,6 +1560,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-render",
"effectcraft-time",
"serde_json",
@ -1566,7 +1569,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@ -1575,7 +1578,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1587,7 +1590,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1599,7 +1602,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@ -1607,7 +1610,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@ -1619,7 +1622,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@ -1651,11 +1654,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.8.0"
version = "0.9.0"
[[package]]
name = "effectcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -6465,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"

View File

@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
native-speaker review.
## Upcoming
@ -117,6 +117,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
catalog gained since 9 October are translated: the Home screen (recent files, their Kind and
Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification,
resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog
and the project, import, export and colour-management forms. `uk` now carries 495 of the 496
panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by
design — alongside its 618/618 menus.
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new

View File

@ -16,7 +16,11 @@ effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
serde = { workspace = true }
serde_json = { workspace = true }
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
# activation when driven by an agent). The same winit eframe runs on.
winit = { version = "0.30", default-features = false }
rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
@ -32,8 +36,6 @@ zbus = "5.19"
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.

View File

@ -117,7 +117,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
},
|e| eprintln!("effectcraft: audio output: {e}"),
|e| log::warn!("audio output: {e}"),
None,
)
.map_err(|e| e.to_string())
@ -187,7 +187,7 @@ impl AudioDevice for CpalOut {
return;
}
if let Err(e) = stream.play() {
eprintln!("effectcraft: audio output: {e}");
log::warn!("audio output: {e}");
return;
}
let _ = stop_rx.recv();

View File

@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}

View File

@ -24,6 +24,7 @@ mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod sheet_dialogs;
mod window;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
@ -88,13 +89,24 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
@ -108,8 +120,8 @@ fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
.with_inner_size([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
@ -120,8 +132,8 @@ fn main() -> eframe::Result {
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
// appears shows how far start-up got (#234).
// Start-up milestones go to stderr and the session log, so a window that never appears
// shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
@ -170,11 +182,14 @@ fn main() -> eframe::Result {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
eprintln!("effectcraft: {e}");
log::warn!("{p}: {e}");
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
@ -183,7 +198,7 @@ fn main() -> eframe::Result {
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
eprintln!("effectcraft: {e}");
log::warn!("import: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
// The ScriptUI panels open when the app last quit open again (a failing one is
@ -264,6 +279,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
}))
@ -315,8 +331,12 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
log::error!("{message}");
eprintln!("effectcraft: {message}");
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{message}");
} else {
eprintln!("effectcraft: {message}");
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
@ -333,6 +353,8 @@ struct Desktop {
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
/// The window's size and position, saved for the next launch.
window: window::Tracker,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
@ -347,6 +369,7 @@ impl eframe::App for Desktop {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
log::info!("first frame drawn");
@ -360,6 +383,7 @@ impl eframe::App for Desktop {
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}

View File

@ -0,0 +1,371 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved in the config directory (`window.json`), moved
//! onto a monitor that is still connected.
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
const FILE: &str = "window.json";
const MAX_BYTES: usize = 4096;
/// Seconds a window opened maximized gets to say so before its size is tracked.
const MAXIMIZE_WAIT: f64 = 2.0;
/// The window's geometry between launches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
pub pos: Option<[i32; 2]>,
/// The inner size in points; while maximized, the size to return to.
pub size: [f32; 2],
pub maximized: bool,
}
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
/// The geometry saved by the last session; `None` when there is none or it can't be read.
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
serde_json::from_str(&text).ok()
}
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
/// once a second, so a session that ends badly still has it) and on exit.
pub struct Tracker {
geometry: Option<WindowGeometry>,
saved: Option<WindowGeometry>,
last_attempt: f64,
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
/// window's first frames report the maximized size as an ordinary one, which would replace
/// the size to return to.
maximizing: Option<f64>,
/// Agent-driven runs (`--control`) leave the user's saved window alone.
save: bool,
}
impl Tracker {
/// `geometry`: what the window opened with.
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
}
/// Per frame.
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
if let Some(until) = self.maximizing {
// A system that never maximizes the window is believed after a moment.
let until = until.min(now + MAXIMIZE_WAIT);
self.maximizing = (!maximized && now < until).then_some(until);
}
if self.maximizing.is_none() {
track(ctx, &mut self.geometry);
}
if now - self.last_attempt >= 1.0 {
self.last_attempt = now;
self.save(config);
}
}
/// Write the geometry when it differs from the saved one.
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
let (Some(config), Some(g)) = (config, self.geometry) else { return };
if !self.save || self.saved == Some(g) {
return;
}
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
Ok(()) => self.saved = Some(g),
Err(e) => log::warn!("could not save the window geometry: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 2560 × 1440: the default size, where the system puts it.
let big = [monitor(0, 0, 2560, 1440, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
}
/// A window restored maximized keeps the size it returns to, though its first frames report
/// the maximized size as an ordinary one.
#[test]
fn opening_maximized_keeps_the_size_to_return_to() {
let ctx = egui::Context::default();
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), Some(g), true);
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
tracker.geometry
};
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
// Un-maximized by the user afterwards: tracked as usual.
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
// A system that doesn't maximize: the window's real size is tracked after the wait.
let mut tracker = Tracker::new(Some(g), Some(g), true);
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
}
/// The reported bug: the window opened at the default size and place on every launch.
#[test]
fn the_window_geometry_is_saved_and_loaded() {
let config = MemoryConfig::default();
assert_eq!(load(&config), None);
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), None, true);
tracker.save(Some(&config));
assert_eq!(load(&config), Some(g));
// Positions the system doesn't report (Wayland) round-trip too.
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
tracker.save(Some(&config));
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
// An agent-driven run leaves the saved window alone.
Tracker::new(Some(g), None, false).save(Some(&config));
assert_eq!(load(&config).and_then(|g| g.pos), None);
// A damaged or oversized file is ignored: the window opens at its default.
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
config.write(FILE, text).unwrap();
assert_eq!(load(&config), None, "{text:.20}");
}
}
}

View File

@ -58,4 +58,9 @@ sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"
[people.Losminek]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"

View File

@ -264,6 +264,21 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
pub fn cover(&mut self, size: [f64; 2]) {
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
let src = std::mem::replace(&mut self.img, Image::new(w, h));
let (sw, sh) = (src.width as usize, src.height as usize);
for y in 0..sh {
let s = y * sw;
let d = (y + dy as usize) * w as usize + dx as usize;
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
}
self.offset[0] += dx as f64;
self.offset[1] += dy as f64;
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
@ -281,6 +296,27 @@ impl Buf {
}
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
return None;
}
// Region and content, both as pixel ranges in the buffer's own coordinates.
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
return None;
}
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {

View File

@ -429,7 +429,8 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
}
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original.
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
@ -440,7 +441,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
}
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
@ -574,6 +575,28 @@ mod tests {
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
}
#[test]
fn noise_stays_inside_the_layer_alpha() {
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = match i % 40 {
0..10 => 0.0,
10..20 => 0.5,
_ => 1.0,
};
*p = p.map(|c| c * a);
}
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
for (o, p) in img.data.iter().zip(&out.data) {
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
}
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
}
}
#[test]
fn sub_settings_and_fractal_types_change_the_result() {
let base = run("ec.noise.fractal", &[]);

View File

@ -5,7 +5,8 @@
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
// Separate Dimensions: X and Y Position default to the comp centre.
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
}
}
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
let mut next = 0;
let mut ids = Ids(&mut next);
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
let mut g = match top.match_id.as_str() {
"transform" if !camera_or_light => return None,
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
"audio" => build::audio(&mut ids),
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
_ => return None,
};
match top.match_id.as_str() {
// Source Text and the animators are the layer's content, not options with a default.
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
// A model's scale and clip come from its file.
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
_ => {}
}
Some(g)
}
/// Static properties of `g` whose value differs from the same property of `default` (matched by
/// match id; properties `default` lacks have no known default and are left out).
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
for c in &g.children {
match c {
Node::Prop(pr) => {
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
out.push(pr.uid);
}
}
Node::Group(sg) => {
if let Some(d) = default.sub(&sg.match_id) {
changed_from(sg, d, out);
}
}
}
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
@ -312,10 +360,27 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = effectcraft_render::source_size(&s.project, l);
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
// modification in itself.
for pr in l.props.props() {
let hit = match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
};
if hit {
out.push(json!({"layer": lid.0, "prop": pr.uid}));
}
}
// Modified: a fresh layer of the same kind gives the defaults to compare against.
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
// A camera's or light's Transform is compared with a fresh one's below.
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
@ -335,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
found.push(g.uid);
}
}
if let Some(fresh) = &fresh {
if let Some(default) = default_group(top, l, fresh) {
changed_from(top, &default, &mut found);
}
// Every layer style counts in full, like an effect; so does each overridden
// Essential Property.
if top.match_id == effectcraft_project::styles::GROUP {
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
}
if let GroupKind::Essential { overridden } = &top.kind {
for u in overridden {
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
found.push(*u);
}
}
}
}
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
}
}

View File

@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = format!("file://{}", path.replace(' ', "%20"));
let url = super::layer_menu::file_url(path);
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
// Enable Logging's file while it is on, else this run's session log.
let file = crate::logging::current_file()
.filter(|_| crate::logging::is_enabled())
.or_else(crate::logging::session_file)
.or_else(crate::logging::current_file)
.unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
@ -616,3 +621,25 @@ pub fn specs() -> Vec<CommandSpec> {
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("run #1 100%.txt");
std::fs::write(&f, "x").unwrap();
let mut s = crate::Session::default();
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -109,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,

View File

@ -1,7 +1,7 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = (w as u32).clamp(4, 30000);
c.width = limits::size(w).max(4);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = (h as u32).clamp(4, 30000);
c.height = limits::size(h).max(4);
}
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = pa.max(0.1);
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;

View File

@ -5,8 +5,9 @@ use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
fn has_roi(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@ -17,7 +18,8 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
/// (all position keys too) so nothing moves on screen.
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
// At most 30,000 px a side, like any composition (#788).
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
s.edit(label, None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.width = w;
@ -163,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
}
// ---------------------------------------------------------------- composition presets
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.savePreset";
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
let dim = |k: &str| {
num(k).and_then(|v| {
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
})
};
let preset =
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
if !preset.is_valid() {
return Err(bad(
C,
format!(
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
CompPreset::MAX_NAME
),
));
}
let presets = &mut s.prefs.comp_presets;
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
Some(q) => {
*q = preset;
true
}
None => {
presets.push(preset);
false
}
};
s.save_prefs();
s.prefs_revision += 1;
Ok(json!({"name": name, "replaced": replaced}))
}
/// `comp.deletePreset`: forget a saved composition preset.
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.deletePreset";
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let before = s.prefs.comp_presets.len();
s.prefs.comp_presets.retain(|q| q.name != name);
if s.prefs.comp_presets.len() == before {
return Err(bad(C, format!("no saved preset named `{name}`")));
}
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
/// `comp.presets`: the saved composition presets.
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
@ -177,6 +238,17 @@ pub fn specs() -> Vec<CommandSpec> {
save_frame
),
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
cmd!(
"comp.savePreset",
"Save Composition Preset",
[],
None,
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
always,
save_preset
),
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
query!("comp.presets", "Composition Presets", "{}", list_presets),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}

View File

@ -405,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: has_layers,
run: generate,
@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: always,
run: open_panel,

View File

@ -809,7 +809,7 @@ fn original_path(s: &Session) -> Option<String> {
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let url = format!("file://{path}");
let url = super::layer_menu::file_url(&path);
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
@ -938,3 +938,25 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
#[test]
fn edit_original_opens_an_encoded_file_url() {
let mut s = crate::Session::default();
let id = s
.edit("add", None, |p, _| {
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
})
.unwrap();
s.state.project_selection = vec![id];
s.drain_events();
let r = s.execute("edit.editOriginal", json!({})).unwrap();
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
}
}

View File

@ -43,8 +43,18 @@ impl ExprError {
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
errors_in(&ctx, s.expr_check, || true).unwrap_or_default()
}
/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between
/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and
/// syntax errors on disabled expressions, just like the explicit `expr.errors` query.
pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option<Vec<ExprError>> {
let mut out = vec![];
for (i, l) in comp.layers.iter().enumerate() {
for (i, l) in ctx.comp.layers.iter().enumerate() {
if !keep_going() {
return None;
}
let mut props: Vec<(&Property, &Expression)> = vec![];
l.props.walk("", &mut |_, p| {
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
}
});
for (p, e) in props {
if !keep_going() {
return None;
}
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
None => None,
}
} else {
s.expr_check.and_then(|check| check(&e.text).err())
check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(cid, i, l, p, message, !e.enabled));
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
}
}
}
out
keep_going().then_some(out)
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {

View File

@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
let (mut proj, repaired) =
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
s.replace_project(proj, Some(path.to_string()));
s.restore_view(&effectcraft_project::saved_view(&text));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
if !repaired.is_empty() {
let more = repaired.len().saturating_sub(3);
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
}
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
let saved_by = effectcraft_project::saved_by(&text);
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
@ -60,7 +69,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
if s.check_footage_on_open {
crate::footage_check::start(s, None, false)?;
}
Ok(json!({"path": path, "savedBy": saved_by}))
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
}
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
@ -89,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
let json = s.project.to_file_json()?;
let json = s.project.to_file_json_with_view(&s.saved_view())?;
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
}

View File

@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rate_of(p: &Value) -> FrameRate {
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
}
fn placeholder_footage(p: &Value) -> Footage {
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
Footage {
path: String::new(),
kind: FootageKind::Video,
@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
.unwrap_or([0.5, 0.5, 0.5]);
Solid {
color,
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
pixel_aspect: 1.0,
}
}
@ -620,7 +620,8 @@ impl Interpretation {
),
};
it.invert_alpha = super::b_p(p, "invertAlpha");
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32);
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
it.fields = match str_p(p, "fields") {
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),

View File

@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
l.start_time = at - src_in;
l.in_point = at;
l.out_point = (at + len).min(comp.duration);
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
l.out_point = at + len;
let lid = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
cm.layers.insert(0, l);
@ -206,8 +207,12 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
let problem = match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => s.footage.problem(f),
_ => None,
};
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
)
}

View File

@ -1,4 +1,4 @@
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
//! performs it. Headless sessions accept and ignore them.
@ -59,11 +59,6 @@ pub fn specs() -> Vec<CommandSpec> {
// Viewer.
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
@ -73,7 +68,9 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
// macOS uses Control+Command+F for native full screen. On Windows and Linux,
// Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp.
fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always),
fe!(
"view.newViewer",
"New Viewer",

View File

@ -5,6 +5,7 @@ use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -92,8 +93,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
@ -114,7 +116,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
match p.get("pixelAspect").and_then(Value::as_f64) {
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
v => Ok(v),
v => Ok(v.map(limits::pixel_aspect)),
}
}
@ -295,7 +297,10 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
// Footage and precomps keep their full length past the comp's end (After Effects); items
// without a duration (solids, comp-length stills) end with the comp.
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
@ -377,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
if b_p(p, "others") == Some(true) {
return set_switch_others(s, cid, &ids, &name, v, &label);
}
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@ -394,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(r))
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
/// the other layers directly.
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
let r = s.edit(label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// The state that sets a layer apart: unlocked for Lock, on for the rest.
let active = !matches!(name, "lock" | "locked");
// Track mattes keep their video off by design.
let mattes: Vec<LayerId> =
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
for l in comp.layers.iter().filter(|l| applies(l)) {
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
Ok(others)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
// edge of several selected layers trims them all together (as in AE).
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
s.edit("Layer Timing", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
@ -891,6 +935,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = outp {
l.out_point = o.max(l.in_point + fd);
}
// Stops at the comp start/end (a point already past it doesn't jump onto
// it), keeping at least one frame of layer.
if let Some(d) = in_delta {
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
}
if let Some(d) = out_delta {
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
}
}
}
}
@ -1429,8 +1481,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
let pixel_aspect = pixel_aspect_p(p)?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
@ -1533,7 +1585,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
has_layers,
set_switch
),
@ -1555,7 +1607,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Timing",
[],
None,
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
has_layers,
timing
),

View File

@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
/// Folder containing a file as a `file://` URL.
pub(crate) fn folder_url(path: &str) -> String {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
format!("file://{dir}")
file_url(&dir)
}
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
pub(crate) fn file_url(path: &str) -> String {
use std::fmt::Write as _;
let path = path.replace('\\', "/");
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
url.push(char::from(b));
} else {
let _ = write!(url, "%{b:02X}");
}
}
url
}
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
@ -845,3 +862,21 @@ pub fn specs() -> Vec<CommandSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::{file_url, folder_url};
#[test]
fn file_urls_are_well_formed() {
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
}
#[test]
fn folder_url_names_the_containing_folder() {
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
}
}

View File

@ -76,6 +76,8 @@ pub(crate) mod watch_folder;
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::open as open_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@ -99,6 +101,8 @@ pub struct CommandSpec {
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Further default shortcuts (the menus and tooltips show `shortcut`).
pub alt_shortcuts: &'static [&'static str],
/// Parameter description for agents and the MCP schema.
pub params: &'static str,
pub enabled: Enabled,
@ -109,8 +113,22 @@ pub struct CommandSpec {
#[macro_export]
macro_rules! cmd {
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
$crate::commands::CommandSpec {
id: $id,
label: $label,
menu: &[$($m),*],
shortcut: $sc,
alt_shortcuts: &[],
params: $params,
enabled: $en,
run: $run,
journal: true,
}
};
}
#[macro_export]
@ -121,6 +139,7 @@ macro_rules! query {
label: $label,
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: $params,
enabled: $crate::commands::always,
run: $run,
@ -449,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
str_p(p, "merge")
}
/// `comp`: item id or name; default the active comp.
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
/// clients often send numbers as strings); default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
match p.get("comp") {
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s
.project
.items
.values()
.find(|i| &i.name == name && i.as_comp().is_some())
.map(|i| i.id)
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
.ok_or_else(|| missing(v)),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
fn digits_id(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
/// that names no layer is resolved as the number it spells.
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
match v {
Value::Number(n) => {
@ -470,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
}
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
}
_ => None,
}

View File

@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
// Footage that shows no picture although its file is there (a codec or profile the
// decoders lack): why, so an agent can tell the user.
if let ItemKind::Footage(f) = &i.kind
&& let Some(why) = s.footage.problem(f)
{
o["problem"] = json!(why);
}
o
})
.collect();
@ -80,6 +87,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}

View File

@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run};
#[allow(unused_macros)]
macro_rules! stub {
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
}

View File

@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
if added == 0 && !kinds.is_empty() {
return Err(bad(C, "unknown animator property"));
}
// Like After Effects, a scripted animator has no selectors until the script adds one
// (addProperty("ADBE Text Selector")); a default 0-100% one would select every character.
if kinds.is_empty()
&& let Some(sels) = g.sub_mut("selectors")
{
sels.children.clear();
}
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;

View File

@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
Ok(switch_view(s, cid, v))
}
/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View),
/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project.
fn set_pane_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let v = view_p(p, "view.setPaneView")?;
let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize;
if pane == 0 {
switch_view(s, cid, v);
} else {
views(s, cid).set_pane(pane - 1, v);
}
let vs = views(s, cid);
Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]}))
}
macro_rules! view_cmd {
($f:ident, $v:expr) => {
fn $f(s: &mut Session, p: &Value) -> Result<Value> {
@ -706,6 +721,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
set_3d_view
),
cmd!(
"view.setPaneView",
"Set View of Pane",
[],
None,
"{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
has_comp,
set_pane_view
),
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),

View File

@ -39,7 +39,10 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
}
None => {
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
// The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop
// too; only explicit `;` fields mean drop-frame (#890).
let drop_frame = fr.supports_drop_frame() && text.contains(';');
let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?;
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
if relative {
Ok(parsed)
@ -61,8 +64,9 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
comp.frame_rate.tick_of(f)
// Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded.
let t = if let Some(f) = f_p(p, "frame") {
comp.frame_rate.tick_of(f.round() as i64)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
@ -169,10 +173,10 @@ fn with_to(p: &Value, to: &str) -> Value {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),

View File

@ -1,7 +1,7 @@
//! View menu commands that change the project (guides) or headless-relevant editor state (the
//! region of interest).
//! View menu commands that change the project (guides, each comp's Resolution) or
//! headless-relevant editor state (the region of interest).
use effectcraft_project::Guide;
use effectcraft_project::{Guide, Resolution};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.region_of_interest))
}
/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved
/// with the project but no undo step ([`Session::set_resolution`]).
fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result<Value> {
let cid = comp_id(s, p)?;
s.set_resolution(cid, r)?;
Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()}))
}
/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks
/// for it (the Custom Resolution dialog).
fn custom_resolution(s: &mut Session, p: &Value) -> Result<Value> {
match f_p(p, "factor") {
Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)),
None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")),
None => super::frontend(s, "view.res.custom", p),
}
}
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
if !matches!(n, 1 | 2 | 4) {
@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(v))
}
/// A `view.res.*` command setting one [`Resolution`].
macro_rules! res {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => {
cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r))
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full),
res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half),
res!("view.res.third", "Third", ["View", "Resolution"], None, Third),
res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter),
cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution),
// The Composition panel's Resolution popup (not in the View menu).
res!("view.res.auto", "Resolution: Auto", [], None, Auto),
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),

View File

@ -341,7 +341,8 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
self.project = a.state[target].clone();
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;

View File

@ -707,9 +707,38 @@ impl Session {
self.events.push(Event::ProjectChanged { revision: self.revision });
}
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
return Ok(());
}
Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r;
self.bump();
Ok(())
}
/// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the
/// project before undo, redo or a history jump restored another state.
pub(crate) fn keep_view_settings(&mut self, from: &Project) {
let keep: Vec<(ItemId, effectcraft_project::Resolution)> =
self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect();
if keep.is_empty() {
return;
}
let p = Arc::make_mut(&mut self.project);
for (id, r) in keep {
if let Some(c) = p.comp_mut(id) {
c.resolution = r;
}
}
}
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@ -720,6 +749,7 @@ impl Session {
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@ -859,6 +889,26 @@ impl Session {
self.events.push(Event::OpenComp(id));
}
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
pub fn saved_view(&self) -> effectcraft_project::SavedView {
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
}
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
/// opened (its first), as files from before the view was saved do.
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
open.push(id);
}
}
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
self.state.open_comps = open;
self.open_comp(active);
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.events.push(Event::Toast { message: msg.into(), error: false });
}
@ -1080,10 +1130,14 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_fuzz_open;
#[cfg(test)]
mod tests_key_metadata_order;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_limits;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;

View File

@ -1,25 +1,47 @@
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
//! and always keeps the last few hundred warnings and errors in memory for the System
//! Compatibility Report.
//! The `log` logger. Records go to standard error and, on the desktop, to a session log that
//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs
//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that
//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
//! errors are also kept in memory for the System Compatibility Report.
//!
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
//! fails before the window opens leaves a trace.
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`).
//!
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
//!
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
//! written first. Writing never panics: a file that can't be created or written leaves standard
//! error as the only sink. Never log the control-channel token.
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::sync::{Mutex, OnceLock, PoisonError};
use log::LevelFilter;
/// The session log's name inside the `Logs` folder.
pub const SESSION_LOG: &str = "effectcraft.log";
/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The session log stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the session log is attached.
pub const MAX_PENDING: usize = 512;
/// The filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,effectcraft=info";
static ENABLED: AtomicBool = AtomicBool::new(false);
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
/// The filter for standard error and the session log: (target prefix or every target, level).
static FILTER: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
static SESSION: Mutex<Sink> = Mutex::new(Sink::new(MAX_FILE_BYTES));
struct State {
file: Option<PathBuf>,
@ -32,15 +54,16 @@ const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
if to_stderr(r.metadata()) {
eprintln!("{line}");
let line = format_line(&timestamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string());
if shown(r.metadata()) {
let _ = writeln!(std::io::stderr(), "{line}");
SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n"));
}
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
@ -59,22 +82,62 @@ impl log::Log for Logger {
fn flush(&self) {}
}
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
fn unix_millis() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0)
}
fn thread_name() -> String {
#[cfg(not(target_arch = "wasm32"))]
return std::thread::current().name().unwrap_or("?").to_string();
#[cfg(target_arch = "wasm32")]
return "main".to_string();
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
fn timestamp(ms: u64) -> String {
let secs = ms / 1000;
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}")
}
/// Install the logger (once; later calls and other installed loggers are left alone).
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
let filter = FILTER.get_or_init(|| {
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
});
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
}
}
/// Whether `RUST_LOG` lets a record through to stderr.
fn to_stderr(m: &log::Metadata) -> bool {
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
/// Whether the filter lets a record through to standard error and the session log.
fn shown(m: &log::Metadata) -> bool {
FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
}
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
@ -110,9 +173,112 @@ pub fn install_panic_hook() {
}));
}
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder.
pub fn log_dir(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs")
}
/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder.
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
log_dir(config_dir).join("EffectCraft Log.txt")
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) }
}
/// Shift the previous session logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped) and create a fresh, empty log file in `dir` (created if missing).
fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
sink.attach(file);
sink.path = Some(path.clone());
Ok(path)
}
/// This run's session log (once [`attach_session_log`] succeeded).
pub fn session_file() -> Option<PathBuf> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone()
}
/// Where lines go besides standard error: kept in memory until a file is attached, then
/// appended to it until it reaches its size cap.
struct Sink {
file: Option<File>,
path: Option<PathBuf>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
const fn new(max_bytes: u64) -> Sink {
Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
fn write(&mut self, line: &str) {
if self.file.is_none() && !self.capped {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// Turn file logging on (writing to `file`) or off.
@ -123,7 +289,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
std::fs::create_dir_all(d)?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
}
if let Ok(mut st) = STATE.lock() {
st.file = Some(file);
@ -136,7 +302,7 @@ pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// The current log file (set once logging was enabled).
/// The current Enable Logging file (set once logging was enabled).
pub fn current_file() -> Option<PathBuf> {
STATE.lock().ok().and_then(|s| s.file.clone())
}
@ -148,7 +314,8 @@ pub fn recent() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{LevelFilter, level_for, parse_filter};
use super::*;
use std::sync::atomic::AtomicUsize;
/// `RUST_LOG` picks what start-up prints to stderr (#234).
#[test]
@ -162,4 +329,111 @@ mod tests {
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
assert!(parse_filter("").is_empty());
}
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
/// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown.
#[test]
fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() {
let f = parse_filter(DEFAULT_FILTER);
assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info);
assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info);
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z");
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
let _ = timestamp(u64::MAX);
}
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).unwrap();
assert_eq!(path, dir.join(SESSION_LOG));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).unwrap();
}
assert_eq!(read(&dir.join(SESSION_LOG)), "run 4");
assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2");
assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::write(&dir, "a file where the directory should be").unwrap();
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"),
"2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full"
);
}
}

View File

@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
}
"layer.markersLock" => Some(layer?.markers_locked),
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => {
Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res.")))
}
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),

View File

@ -32,6 +32,10 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Українська", "uk"),
("Español", "es"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
@ -172,6 +176,8 @@ page!(Appearance {
dark_theme: String = "dark".into(),
/// The theme used in light appearance: `light`.
light_theme: String = "light".into(),
/// Theme shortcuts in the appearance context menu.
favorite_themes: Vec<String> = Vec::new(),
/// User interface brightness, -1 (darker) … 1 (lighter).
brightness: f64 = 0.0,
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
@ -332,6 +338,42 @@ pub struct LabelPref {
pub color: String,
}
/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the
/// frame size, pixel aspect and frame rate, as in After Effects' own presets.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompPreset {
pub name: String,
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: f64,
}
impl CompPreset {
/// Longest preset name kept.
pub const MAX_NAME: usize = 128;
/// Values a composition accepts (a hand-edited settings file can hold anything).
pub fn is_valid(&self) -> bool {
let size = 4..=30000;
!self.name.trim().is_empty()
&& self.name.chars().count() <= Self::MAX_NAME
&& size.contains(&self.width)
&& size.contains(&self.height)
&& self.pixel_aspect.is_finite()
&& (0.01..=100.0).contains(&self.pixel_aspect)
&& self.frame_rate.is_finite()
&& (1.0..=999.0).contains(&self.frame_rate)
}
}
/// Saved composition presets, skipping entries that don't parse instead of losing the list.
fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<CompPreset>, D::Error> {
let v = Vec::<Value>::deserialize(d)?;
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
}
fn default_labels() -> Vec<LabelPref> {
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
}
@ -390,6 +432,9 @@ pub struct Prefs {
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
pub custom_rgb: crate::viewer::CustomRgb,
/// Composition Settings presets the user saved, in the order saved.
#[serde(deserialize_with = "lenient_comp_presets")]
pub comp_presets: Vec<CompPreset>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@ -425,6 +470,7 @@ impl Default for Prefs {
recent_fonts: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
comp_presets: vec![],
extra: Extra::new(),
}
}
@ -550,6 +596,7 @@ impl Prefs {
/// Clamp values into their valid ranges and fill missing labels.
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
self.comp_presets.retain(CompPreset::is_valid);
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
self.startup.window_graphics = Startup::default().window_graphics;
}
@ -564,6 +611,8 @@ impl Prefs {
a.interval_minutes = a.interval_minutes.clamp(1, 240);
a.max_versions = a.max_versions.clamp(1, 99);
let ap = &mut self.appearance;
let mut seen = std::collections::BTreeSet::new();
ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone()));
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
ap.appearance_mode = Appearance::default().appearance_mode;
}
@ -620,6 +669,23 @@ impl Prefs {
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
return Err(format!("`{key}` expects one of {}", all.join(", ")));
}
let favorites = if key == "appearance.favoriteThemes" {
Some(&value)
} else if key == "appearance" {
value.get("favoriteThemes")
} else {
None
};
if let Some(favorites) = favorites {
let values = favorites.as_array().ok_or("favorite themes must be a list")?;
let mut seen = std::collections::BTreeSet::new();
for value in values {
let id = value.as_str().ok_or("favorite theme must be a string")?;
if !is_choice(THEMES, id) || !seen.insert(id) {
return Err("unknown or duplicate favorite theme".into());
}
}
}
// Older clients set the single theme (by key, or inside the page object): it still picks
// a visible theme, fixing the mode to its family.
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
@ -639,8 +705,8 @@ impl Prefs {
Ok(())
}
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
/// kept.
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
/// the saved composition presets are kept.
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
@ -650,6 +716,7 @@ impl Prefs {
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
preview: std::mem::take(&mut self.preview),
comp_presets: std::mem::take(&mut self.comp_presets),
..d
};
return Ok(());

View File

@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
}
fn new_comp(cx: &Ctx) -> Comp {
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
let size = |v: u32| effectcraft_project::limits::size(v.into());
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
}
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {

View File

@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
// A plain entry also gets its command's further default keys.
let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] };
let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect();
push(
Bindable {
key,
@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
push(
Bindable {
key: c.id.to_string(),

View File

@ -628,3 +628,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
}
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
#[test]
fn reopening_a_project_restores_its_active_comp() {
use effectcraft_project::ItemId;
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("view.ecproj").to_string_lossy().to_string();
let mut s = Session::default();
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
s.state.open_comps.retain(|id| id.0 != c);
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
assert!(!s2.is_dirty());
// A file from before the view was saved opens on its first comp, as it always did.
let text = std::fs::read_to_string(&path).unwrap();
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = old.as_object_mut().unwrap();
obj.remove("activeComp");
obj.remove("openComps");
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
obj_set(&mut old, json!(999), json!([999, b, "x"]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s4 = Session::default();
s4.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
obj_set(&mut old, json!(999), json!([]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s5 = Session::default();
s5.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// XML copies carry it too.
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": xml})).unwrap();
let mut s6 = Session::default();
s6.execute("file.open", json!({"path": xml})).unwrap();
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
let _ = std::fs::remove_dir_all(dir);
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
let o = v.as_object_mut().unwrap();
o.insert("activeComp".into(), active);
o.insert("openComps".into(), open);
}
}

View File

@ -243,3 +243,25 @@ fn demo_has_3d_showcase() {
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
assert!(lit > 2_000, "{lit}");
}
/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and
/// editor states saved before panes existed load with the default Top / Front / Right.
#[test]
fn layout_panes_choose_their_view() {
use effectcraft_render::three_d::{View3D, Views3D};
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top);
let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap();
assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"]));
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched");
s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::Custom1);
assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err());
assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err());
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom);
let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap();
assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right));
}

View File

@ -0,0 +1,342 @@
//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an
//! error, never a panic, an abort or a hang (AGENTS.md "Never crash").
//!
//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one
//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips,
//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON
//! node replaced by an extreme number, another type or another subtree; members dropped,
//! elements duplicated), then opened through the real `file.open` path, followed by the queries
//! the app and agents run on a fresh project and a thumbnail-size render of the first frame.
//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang
//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack
//! overflow can't be caught and aborts the run, which fails it all the same.
//!
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_`
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call.
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::{Services, Session};
const PATH: &str = "/fuzz/input.ecproj";
/// splitmix64: tiny, good enough for picking mutations.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// `0..n` (`0` when `n` is 0).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
}
/// One file, served from memory; everything else is missing.
struct OneFile(Vec<u8>);
impl Services for OneFile {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) }
}
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::other("the fuzzer writes nothing"))
}
fn exists(&self, path: &str) -> bool {
path == PATH
}
fn stamp(&self, _: &str) -> Option<(u64, u128)> {
None
}
fn list_dir(&self, _: &str) -> Vec<String> {
vec![]
}
fn is_dir(&self, _: &str) -> bool {
false
}
}
/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when
/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open
/// itself, or one the command guard caught in a query.
fn open_input(bytes: Vec<u8>) -> Result<bool, String> {
let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() };
if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() {
return Ok(false); // refused: fine
}
let crashed = |r: crate::Result<Value>| match r {
Err(e) if e.to_string().contains("internal error") => Err(e.to_string()),
_ => Ok(()),
};
crashed(s.execute("project.summary", json!({})))?;
let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>())).collect();
for (comp, layers) in comps.into_iter().take(4) {
crashed(s.execute("comp.info", json!({ "comp": comp })))?;
for layer in layers.into_iter().take(8) {
crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?;
}
}
// The first frame the viewer shows, at thumbnail size.
if let Some(id) = s.state.active_comp
&& let Some(c) = s.project.comp(id)
{
let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0);
effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale);
}
Ok(true)
}
/// Run one input on its own thread: a panic becomes its message, a hang a timeout.
fn run_guarded(bytes: Vec<u8>, deadline: Duration) -> Result<bool, String> {
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes)));
let r = match r {
Ok(r) => r,
Err(p) => {
Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()))
}
};
let _ = tx.send(r);
});
if let Err(e) = spawned {
return Err(format!("couldn't start the fuzz thread: {e}"));
}
rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
}
/// Small projects covering what a project holds: the empty project, and the demo cut down to
/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects,
/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole
/// demo (700 kB) would make every input slow.
fn seeds() -> Vec<Vec<u8>> {
let demo = crate::demo::demo_project();
let mut projects = vec![effectcraft_project::Project::default()];
for (cid, c) in demo.comps() {
for i in 0..c.layers.len() {
let mut p = demo.clone();
for (id, item) in p.items.iter_mut() {
if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind {
let keep = if id == cid { i } else { 0 };
let layers = &mut Arc::make_mut(c).layers;
*layers = layers.get(keep).cloned().into_iter().collect();
}
}
projects.push(p);
}
}
let mut out = vec![];
for p in projects {
let json = p.to_file_json().unwrap();
out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes());
out.push(json.into_bytes());
}
out
}
#[rustfmt::skip]
const TOKENS: &[&str] = &[
"{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615",
"-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "</", "/>", "<arr>", "</arr>", "<obj>",
"<m n=\"", "</m>", "<num>", "</num>", "<str>", "</str>", "<null/>", "<!--", "-->", "<?", "?>", "&", ";", "&#x110000;",
"&#xD800;", "&#;", "&#x;", "&amp", "\u{feff}", "é", "\u{1F600}",
];
const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0];
const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993];
/// Every node of `v`, depth first (the root is 0).
fn count(v: &Value) -> usize {
1 + match v {
Value::Array(a) => a.iter().map(count).sum(),
Value::Object(m) => m.values().map(count).sum(),
_ => 0,
}
}
/// The `k`-th node (depth first; the root is 0).
fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> {
if *k == 0 {
return Some(v);
}
*k -= 1;
match v {
Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)),
Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)),
_ => None,
}
}
fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> {
nth(v, &mut { k })
}
fn interesting(r: &mut Rng, around: &Value) -> Value {
match r.below(7) {
0 => json!(NUMBERS[r.below(NUMBERS.len())]),
1 => json!(INTEGERS[r.below(INTEGERS.len())]),
2 => match around.as_f64() {
Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]),
None => Value::Null,
},
3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]),
4 => Value::Bool(r.chance(2)),
5 => json!([]),
_ => Value::Null,
}
}
/// A structural mutation of a project's JSON (when the input still parses).
fn mutate_tree(r: &mut Rng, v: &mut Value) {
let n = count(v);
for _ in 0..1 + r.below(4) {
let k = r.below(n.max(1));
let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null);
let Some(node) = node_mut(v, k) else { return };
match r.below(6) {
0 | 1 => *node = interesting(r, node),
2 => *node = donor,
3 => match node {
Value::Object(m) if !m.is_empty() => {
let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default();
m.remove(&key);
}
Value::Array(a) if !a.is_empty() => {
a.remove(r.below(a.len()));
}
_ => *node = Value::Null,
},
4 => {
if let Value::Array(a) = node
&& !a.is_empty()
{
let i = r.below(a.len());
let x = a[i].clone();
a.insert(r.below(a.len() + 1), x);
}
}
_ => {
if let Value::Object(m) = node {
m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null));
}
}
}
}
}
fn mutate_bytes(r: &mut Rng, b: &mut Vec<u8>) {
for _ in 0..1 + r.below(3) {
let len = b.len();
match r.below(8) {
0 if len > 0 => {
let i = r.below(len);
b[i] ^= 1 << r.below(8);
}
1 if len > 0 => {
let i = r.below(len);
b[i] = r.next() as u8;
}
2 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(64)).min(len);
b.drain(a..e);
}
3 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(256)).min(len);
let chunk = b[a..e].to_vec();
let at = r.below(len + 1);
b.splice(at..at, chunk);
}
4 => {
let t = TOKENS[r.below(TOKENS.len())].as_bytes();
let at = r.below(len + 1);
b.splice(at..at, t.iter().copied());
}
5 if len > 0 => b.truncate(r.below(len)),
6 => {
// Deep nesting: what a recursive reader overflows its stack on.
let (open, close) = [("[", "]"), ("<arr>", "</arr>"), ("<obj><m n=\"a\">", "</m></obj>")][r.below(3)];
let depth = [200, 5_000, 200_000][r.below(3)];
// Where the reader expects a value: after an opening token of the same kind.
let opens: Vec<usize> = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect();
let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] };
let mut nest = open.repeat(depth).into_bytes();
nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes());
b.splice(at..at, nest);
}
_ => {}
}
}
}
/// One fuzz input from the seeds, decided by the iteration's own generator.
fn mutate(r: &mut Rng, seeds: &[Vec<u8>]) -> Vec<u8> {
let mut b = seeds[r.below(seeds.len())].clone();
if r.chance(2) {
let text = String::from_utf8_lossy(&b).to_string();
let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) };
if let Some(mut v) = json.and_then(|j| serde_json::from_str::<Value>(&j).ok()) {
mutate_tree(r, &mut v);
let j = v.to_string();
b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() };
}
}
if r.chance(3) || b.is_empty() {
mutate_bytes(r, &mut b);
}
b
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
#[test]
fn fuzz_file_open_never_crashes() {
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022);
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000);
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6));
let seeds = seeds();
// The unmutated seeds open.
for s in &seeds {
assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true));
}
let began = Instant::now();
let mut failures = vec![];
let (mut ran, mut opened) = (0, 0);
for i in start..start + iters {
if began.elapsed() > budget {
break;
}
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
let input = mutate(&mut r, &seeds);
ran += 1;
match run_guarded(input.clone(), Duration::from_secs(10)) {
Ok(o) => opened += usize::from(o),
Err(e) => {
let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string();
failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len()));
if failures.len() >= 5 {
break;
}
}
}
}
assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed());
}

View File

@ -0,0 +1,104 @@
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
//! opened and when a command sets them (#788).
use effectcraft_project::{ItemKind, limits};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Event, Session};
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
let mut v: Value = serde_json::from_str(text).unwrap();
let items = v["items"].as_object_mut().unwrap();
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
edit(&mut it["kind"]);
}
serde_json::to_string(&v).unwrap()
}
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
/// samples, holding a solid of 4 billion px.
fn hostile_project() -> String {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let text = tamper(&text, "Comp", |c| {
c["width"] = json!(4_000_000_000u64);
c["height"] = json!(0);
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
c["duration"] = json!(-5);
c["motion_blur_samples"] = json!(4_000_000_000u64);
});
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
}
#[test]
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
let json_path = tmp("Hostile.ecproj");
let text = hostile_project();
std::fs::write(&json_path, &text).unwrap();
let xml_path = tmp("Hostile.ecprojx");
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
for path in [json_path, xml_path] {
let mut s = Session::default();
let r = s.execute("file.open", json!({"path": path})).unwrap();
let repaired = r["repaired"].as_array().unwrap();
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
assert!(toast, "{path}");
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
// The comp renders at Full resolution, at its (capped) size.
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
}
}
#[test]
fn a_project_within_the_limits_opens_unchanged() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(p, *s.project);
}
#[test]
fn commands_cap_sizes_at_30000_px() {
let mut s = Session::default();
// 2^32 + 10 used to wrap to 10 px.
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
let c = s.active_comp().unwrap().clone();
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
// Layer Settings on the new (selected) solid layer.
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
// A placeholder can't ask for a huge frame or a zero frame rate either.
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}

View File

@ -52,6 +52,41 @@ fn layer_quality_sampling_frame_blending_and_undo() {
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
/// other layers come back the next time.
#[test]
fn set_switch_others() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let c = solid(&mut s, "#0000ff");
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l)));
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap();
// B is hidden: it is shown, alone; then the others come back; one undo step each.
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
// Solo.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]);
// Lock: the layer stays unlocked and the others lock, then unlock.
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]);
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.locked), [false; 3]);
// A track matte keeps its video off.
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
assert_eq!(get(&s, |l| l.switches.video), [false, true, true]);
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
}
#[test]
fn layer_switches_menu() {
let mut s = comp();
@ -1416,3 +1451,16 @@ fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
}
/// The Interpret Footage form sends "Loop (times)" as a float (3.0); it must still be applied (#909).
#[test]
fn interpret_footage_loop_from_form_float() {
let mut s = comp();
let p = s.execute("file.importPlaceholder", json!({"name": "Shot", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
s.state.project_selection = vec![effectcraft_project::ItemId(p)];
s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap();
match &s.project.item(effectcraft_project::ItemId(p)).unwrap().kind {
ItemKind::Footage(f) => assert_eq!(f.loop_count, 3),
_ => panic!(),
}
}

View File

@ -185,6 +185,16 @@ fn scopes_read_the_comp_frame() {
// ---------------------------------------------------------------- Footage panel
#[test]
fn footage_panel_overlay_keeps_full_length_past_the_comp_end() {
let (mut s, cid, _clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
s.execute("footage.open", json!({"item": item.0})).unwrap();
let r = s.execute("footage.overlayEdit", json!({"item": item.0, "in": 0.0, "out": 4.0, "at": 6.0})).unwrap();
let ov = LayerId(r["layer"].as_u64().unwrap());
let l = s.project.comp(cid).unwrap().layer(ov).unwrap();
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (6.0, 10.0));
}
#[test]
fn footage_panel_overlay_and_ripple_insert() {
let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
@ -629,3 +639,36 @@ fn media_browser_over_a_virtual_tree() {
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
}
/// Footage the source can't decode is reported with its reason by `project.summary` and
/// `footage.info`, so an agent can tell the user why the picture is black.
#[test]
fn footage_that_cannot_be_decoded_reports_why() {
struct Broken;
impl FootageSource for Broken {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn problem(&self, f: &Footage) -> Option<String> {
Some(format!("{} isn't supported by the built-in decoder", f.codec))
}
}
let mut s = Session { footage: Arc::new(Broken), ..Default::default() };
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap();
let f = Footage {
kind: FootageKind::Video,
path: "a.mp4".into(),
codec: "H.264 (High 4:2:2)".into(),
width: 64,
height: 36,
has_video: true,
..Default::default()
};
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(f));
let sum = s.execute("project.summary", json!({})).unwrap();
let it = sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(item.0)).unwrap();
assert_eq!(it["problem"], json!("H.264 (High 4:2:2) isn't supported by the built-in decoder"));
assert!(sum["items"].as_array().unwrap().iter().filter(|i| i["id"] != json!(item.0)).all(|i| i.get("problem").is_none()), "only footage reports one");
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
}

View File

@ -212,6 +212,50 @@ fn prefs_commands_get_set_reset_and_persist() {
);
}
/// Composition Settings presets the user saves live in the settings: they persist, a save with
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
/// settings file) are dropped on load.
#[test]
fn composition_presets_save_replace_delete_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps});
assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false}));
s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap();
assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true));
let list = s.execute("comp.presets", json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 2);
assert_eq!(list[0]["name"], "Edit 1440p 60");
assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0)));
assert_eq!(list[1]["width"], json!(1080), "replaced in place");
for bad in [
p("", 1920, 30.0),
p("Tiny", 2, 30.0),
p("Fast", 1920, 5000.0),
json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}),
json!({"name": "x"}),
] {
assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}");
}
// Persisted, and kept by Reset All.
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
s2.load_settings();
assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets);
s2.execute("prefs.reset", json!({})).unwrap();
assert_eq!(s2.prefs.comp_presets.len(), 2);
s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap();
assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone");
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
s3.load_settings();
assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
// A hand-edited settings file: the broken preset is dropped, the good one kept.
let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap();
v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN}));
v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7}));
let back = Prefs::from_json(&v.to_string());
assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
}
#[test]
fn undo_levels_are_honoured() {
let mut s = session_with_comp();
@ -331,6 +375,24 @@ fn defaults_have_no_conflicts_and_match_the_menus() {
assert!(t.bindables.len() > 500);
}
/// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames).
#[test]
fn cmd_arrows_step_frames_like_page_keys() {
let s = Session::default();
let t = s.shortcuts();
for (id, keys) in [
("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]),
("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]),
("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]),
("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]),
] {
assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}");
// The menus and tooltips keep showing the Page key.
assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0]));
}
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
}
#[test]
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
let store = Arc::new(MemoryConfig::default());
@ -587,7 +649,7 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br"));
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
let saved = store.read(PREFS_FILE).unwrap();
for bad in [json!("fr"), json!(""), json!(17), json!(null)] {
for bad in [json!("xx"), json!(""), json!(17), json!(null)] {
assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err());
assert_eq!(s.prefs.general.language, "ja");
assert_eq!(store.read(PREFS_FILE).unwrap(), saved);
@ -613,6 +675,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
let mut es = Session { config: Some(store.clone()), ..Default::default() };
es.load_settings();
assert_eq!(es.prefs.general.language, "es");
// Czech uses the same validation, persistence and JSON round-trip as every catalog.
assert!(crate::prefs::LANGUAGES.contains(&("Čeština", "cs")));
s.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap();
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("cs"));
let mut cs = Session { config: Some(store.clone()), ..Default::default() };
cs.load_settings();
assert_eq!(cs.prefs.general.language, "cs");
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
let mut reloaded = Session { config: Some(store), ..Default::default() };
reloaded.load_settings();

View File

@ -420,3 +420,19 @@ fn camera_focus_commands_undo_and_serde() {
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
#[test]
fn numeric_string_resolves_as_id() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.resolve_comp(Some(&json!(cid.0.to_string()))).unwrap(), cid);
assert!(s.resolve_comp(Some(&json!("999999"))).is_err());
let comp = s.project.comp(cid).unwrap();
assert_eq!(crate::commands::resolve_layer(comp, &json!(l.to_string())), Some(LayerId(l)));
assert_eq!(crate::commands::resolve_layer(comp, &json!("999999")), None);
let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap();
assert_eq!(r["id"].as_u64(), Some(l), "{r}");
}

View File

@ -414,6 +414,25 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15");
}
/// #890: at 29.97 / 59.94 fps the displayed (non-drop) timecode was read back as drop-frame, so
/// committing the current-time field moved the playhead to another frame from minute 1 on.
#[test]
fn time_set_round_trips_the_displayed_timecode_at_drop_frame_rates() {
for fps in [29.97, 59.94] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "frameRate": fps, "duration": 4000})).unwrap();
for frame in [0_i64, 1799, 1800, 1801, 3600, 17982, 17983, 100_000] {
let r = s.execute("time.set", json!({"frame": frame})).unwrap();
let display = r["display"].as_str().unwrap().to_string();
let r = s.execute("time.set", json!({"timecode": display})).unwrap();
assert_eq!(r["frame"], frame, "{fps} fps: displayed {display}");
}
// Explicit drop-frame fields still mean drop-frame.
let r = s.execute("time.set", json!({"timecode": "0;01;00;02"})).unwrap();
assert_eq!(r["frame"], if fps > 30.0 { 3598 } else { 1800 });
}
}
#[test]
fn time_set_keeps_the_frame_shown_in_the_current_time_field() {
let mut s = Session::default();
@ -712,6 +731,89 @@ fn deleting_time_remap_turns_time_remapping_off() {
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1);
}
/// U (keyframes), the animation reveal and UU (modified) take in Time Remap, which sits at the
/// top of the layer's tree rather than in a group.
#[test]
fn reveal_shortcuts_take_in_time_remap() {
let (mut s, l) = precomp_setup();
let revealed = |s: &mut Session, kind: &str| -> Vec<u64> {
let r = s.execute("anim.reveal", json!({"kind": kind})).unwrap();
r["props"].as_array().unwrap().iter().filter(|p| p["layer"] == l).filter_map(|p| p["prop"].as_u64()).collect()
};
for kind in ["keyframes", "animation", "modified"] {
assert!(revealed(&mut s, kind).is_empty(), "{kind}: nothing to reveal before remapping");
}
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let uid = prop(&s, l, "timeRemap").uid;
for kind in ["keyframes", "animation", "modified"] {
assert_eq!(revealed(&mut s, kind), vec![uid], "{kind}");
}
}
/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text
/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre.
#[test]
fn modified_reveal_is_empty_for_untouched_layers() {
let (mut s, solid) = setup();
let mut layers = vec![("solid", solid)];
for (cmd, params) in [
("layer.newText", json!({"text": "Hi"})),
("layer.newShape", json!({})),
("layer.newNull", json!({})),
("layer.newCamera", json!({})),
("layer.newLight", json!({})),
("layer.newLight", json!({"type": "ambient"})),
] {
let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}"));
layers.push((cmd, r["layer"].as_u64().unwrap()));
}
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
for (what, l) in layers {
s.execute("layer.select", json!({"layers": [l]})).unwrap();
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
let paths: Vec<String> = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect();
assert!(paths.is_empty(), "{what}: {paths:?}");
}
}
/// Match-id path of property or group `uid` of `layer`.
fn path_of(s: &Session, layer: u64, uid: u64) -> String {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
let mut out = String::new();
for n in l.props.node_chain(uid).unwrap_or_default() {
out.push('/');
out.push_str(n.match_id());
}
out
}
/// UU takes in static changes outside Transform: Material, Camera and Light Options, a
/// separated position, and layer styles in full.
#[test]
fn modified_reveal_takes_in_options_and_layer_styles() {
let (mut s, solid) = setup();
let modified = |s: &mut Session, l: u64| -> Vec<String> {
s.execute("layer.select", json!({"layers": [l]})).unwrap();
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect()
};
s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap();
assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]);
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap();
assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]);
s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap();
assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid));
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap();
assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]);
let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap();
assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]);
}
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
/// the end key at the source's end, not at the extended out point (#290).
#[test]
@ -946,3 +1048,75 @@ fn proportional_share_is_linear_in_selection_order() {
assert_eq!(proportional_share(0, 0), 1.0);
assert_eq!(proportional_share(usize::MAX, 3), 1.0);
}
#[test]
fn add_item_keeps_footage_longer_than_the_comp_at_full_length() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
let f = effectcraft_project::Footage {
width: 100,
height: 100,
kind: effectcraft_project::FootageKind::Video,
has_video: true,
duration: effectcraft_time::Tick::from_seconds_f64(10.0),
..Default::default()
};
let item = std::sync::Arc::make_mut(&mut s.project).add_item("long.mp4", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
let l = s.execute("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
assert_eq!(layer.out_point.seconds(), 10.0);
}
/// View > Go to Time's dialog sends its Frame field as a float; `time.set` used to reject it
/// with "need `time`, `frame` or `timecode`".
#[test]
fn go_to_time_accepts_a_float_frame_from_the_dialog() {
let (mut s, _) = setup();
let r = s.execute("time.set", json!({"frame": 12.0})).unwrap();
assert_eq!(r["frame"], json!(12));
let r = s.execute("time.set", json!({"frame": 7})).unwrap();
assert_eq!(r["frame"], json!(7));
}
/// Dragging a trim edge with several layers selected trims every selected layer by the same
/// amount from its own in/out point (as in AE), clamped to the comp and to one frame of layer;
/// a whole merged drag is one undo step.
#[test]
fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [a], "in": 0.5, "out": 2.0})).unwrap();
s.execute("layer.timing", json!({"layers": [b], "in": 1.0, "out": 3.5})).unwrap();
let io = |s: &Session, l: u64| {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
(l.in_point.seconds(), l.out_point.seconds())
};
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
let undo_len = s.history.undo.len();
// In-point drag in frame steps, merged into one edit.
for _ in 0..3 {
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 0.1, "merge": "bar-trim"})).unwrap();
}
assert_eq!(s.history.undo.len(), undo_len + 1);
let ((ai, ao), (bi, bo)) = (io(&s, a), io(&s, b));
assert!(near(ai, 0.8) && near(ao, 2.0) && near(bi, 1.3) && near(bo, 3.5), "{ai} {ao} {bi} {bo}");
s.execute("edit.undo", json!({})).unwrap();
assert!(near(io(&s, a).0, 0.5) && near(io(&s, b).0, 1.0));
s.history.merge_key = None;
// Out-point drag: b hits the comp end (4 s) while a keeps going.
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 1.0})).unwrap();
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
// In-point drag left: a stops at the comp start.
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -0.8})).unwrap();
assert!(near(io(&s, a).0, 0.0) && near(io(&s, b).0, 0.2), "{:?} {:?}", io(&s, a), io(&s, b));
// Trimming past the other edge keeps one frame of layer.
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 10.0})).unwrap();
let fd = 1.0 / 30.0;
assert!(near(io(&s, a).0, 3.0 - fd) && near(io(&s, b).0, 4.0 - fd), "{:?} {:?}", io(&s, a), io(&s, b));
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": -10.0})).unwrap();
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
// A point already outside the comp trims from where it is instead of jumping onto the edge.
s.execute("layer.timing", json!({"layers": [a], "in": -1.0})).unwrap();
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
}

View File

@ -574,3 +574,66 @@ fn graph_transform_box_scales_key_times_and_values() {
assert_eq!(p.keys[1].value.as_f64(), 50.0);
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
}
// ---------------------------------------------------------------- resolution
#[test]
fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
use effectcraft_project::Resolution;
let mut s = comp();
let a = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap();
let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id()));
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap());
let r = s.execute_checked("view.res.half", json!({})).unwrap();
assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2)));
// No undo step, but the project is modified (it's saved with it).
assert_eq!(s.history.undo.len(), steps);
assert!(s.is_dirty());
assert!(s.active_comp().unwrap().same_pixels(&pixels));
// Another comp has its own; switching back shows comp A's again.
s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
let b = s.active_comp_id().unwrap();
assert_ne!(a, b);
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap();
s.open_comp(a);
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half);
assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half"));
assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true));
assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false));
// Undo and redo restore the comps, not their resolutions.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(b).is_none(), "undid New Composition");
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid");
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
// The comp's explicit `comp` parameter; the same value again changes nothing.
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
let rev = s.revision;
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
assert_eq!(s.revision, rev);
// Saved with the project and read back.
s.execute("file.save", json!({})).unwrap();
assert!(!s.is_dirty());
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half);
assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter);
let _ = std::fs::remove_file(path);
// Custom without a factor asks the frontend (the Custom Resolution dialog).
s.drain_events();
s.execute_checked("view.res.custom", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom")));
assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err());
s.execute_checked("view.res.auto", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
}

View File

@ -3,13 +3,15 @@
//!
//! Nothing here touches the project; edits go through commands.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::{Layer, LayerId};
use effectcraft_keyframe::Value;
use effectcraft_project::{Layer, LayerId, Property};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::{EvalCtx, ExprHost};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
@ -274,10 +276,45 @@ pub struct SnapOptions {
pub grid_spacing: f64,
}
type SnapValues = HashMap<(u64, u64, u64, i64), Result<Value, String>>;
/// Bounds, vertices and transforms can ask for the same expression repeatedly. Share its
/// value only within this immutable snap query; a new pointer update starts a fresh cache.
struct SnapExpressions<'a> {
inner: &'a dyn ExprHost,
values: Mutex<SnapValues>,
}
impl ExprHost for SnapExpressions<'_> {
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String> {
let key = (ctx.comp_id.0, layer.id.0, prop.uid, ctx.time.0);
if let Some(value) = self.values.lock().unwrap_or_else(PoisonError::into_inner).get(&key).cloned() {
return value;
}
// Never hold the lock while evaluating: expressions may query other properties.
let result = self.inner.eval(ctx, layer, prop, value);
let mut values = self.values.lock().unwrap_or_else(PoisonError::into_inner);
if values.len() < 8192 {
values.insert(key, result.clone());
}
result
}
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
self.inner.eval_text_selector(ctx, layer, prop, index, total, selector)
}
}
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
/// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and
/// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped.
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let memo = ctx.expr.map(|inner| SnapExpressions { inner, values: Mutex::default() });
let ctx = EvalCtx { expr: memo.as_ref().map(|h| h as &dyn ExprHost), ..*ctx };
targets_in(&ctx, exclude, opts)
}
fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let comp = ctx.comp;
let (w, h) = (comp.width as f64, comp.height as f64);
let f = opts.features;
@ -1063,3 +1100,45 @@ pub struct Snapshot {
pub scale: f64,
pub image: Arc<Image>,
}
#[cfg(test)]
mod snap_expression_tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
struct PositionHost(AtomicUsize);
impl ExprHost for PositionHost {
fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::Relaxed);
let v = value.components();
Ok(Value::Vec3([v[0] + 10. * ctx.time.seconds(), v[1], 0.]))
}
}
#[test]
fn snapping_reuses_expressions_within_one_query_but_refreshes_time_and_edits() {
let mut s = crate::Session::default();
s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap();
let lid = s.execute("layer.newSolid", json!({"width":40,"height":20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer":lid,"path":"transform/position","expression":"value"})).unwrap();
let cid = s.active_comp_id().unwrap();
let host = PositionHost(AtomicUsize::new(0));
let opts = SnapOptions { layers: true, ..Default::default() };
let query = |s: &crate::Session, time, cached| {
let ctx = EvalCtx { expr: Some(&host), ..EvalCtx::new(&s.project, cid, s.project.comp(cid).unwrap(), time) };
if cached { targets(&ctx, &[], opts) } else { targets_in(&ctx, &[], opts) }
};
let expected = query(&s, Tick::ZERO, false);
assert!(host.0.swap(0, Ordering::Relaxed) > 1, "fixture must visit the same expression for multiple geometry features");
assert_eq!(query(&s, Tick::ZERO, true), expected);
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
let moved = query(&s, Tick::from_seconds_f64(1.), true);
assert_ne!(moved, expected, "a new time must not reuse stale targets");
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
s.execute("prop.set", json!({"layer":lid,"path":"transform/position","value":[200,100,0]})).unwrap();
let edited = query(&s, Tick::ZERO, true);
assert_ne!(edited, expected, "a new project revision must not reuse stale targets");
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
}
}

View File

@ -18,6 +18,11 @@ use serde_json::{Map, Value};
const ROOT: &str = "EffectCraftProject";
/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at
/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply
/// nested file overflowed the stack and aborted the app instead of failing to open (#779).
const MAX_DEPTH: usize = 128;
/// Whether `text` looks like an XML project.
pub fn is_xml(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
@ -158,7 +163,10 @@ impl<'a> P<'a> {
self.i += end;
t
}
fn value(&mut self) -> Result<Value, String> {
fn value(&mut self, depth: usize) -> Result<Value, String> {
if depth > MAX_DEPTH {
return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels"));
}
let (name, _, closed) = self.open()?;
let v = match (name.as_str(), closed) {
("null", _) => Value::Null,
@ -175,7 +183,7 @@ impl<'a> P<'a> {
if self.rest().starts_with("</") {
break;
}
a.push(self.value()?);
a.push(self.value(depth + 1)?);
}
Value::Array(a)
}
@ -190,7 +198,7 @@ impl<'a> P<'a> {
if tag != "m" {
return self.err("expected <m n=\"…\">");
}
let v = self.value()?;
let v = self.value(depth + 1)?;
self.close("m")?;
m.insert(key.unwrap_or_default(), v);
}
@ -243,7 +251,7 @@ pub fn from_xml(text: &str) -> Result<String, String> {
if root != ROOT {
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
}
let v = p.value()?;
let v = p.value(0)?;
p.close(ROOT)?;
serde_json::to_string(&v).map_err(|e| e.to_string())
}
@ -262,4 +270,18 @@ mod tests {
assert_eq!(back, j);
assert!(from_xml("<Other/>").is_err());
}
/// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the
/// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads.
#[test]
fn deep_nesting_is_refused_not_a_stack_overflow() {
let nested = |n: usize| format!("<{ROOT}>{}<null/>{}</{ROOT}>", "<arr>".repeat(n), "</arr>".repeat(n));
let e = from_xml(&nested(1_000_000)).unwrap_err();
assert!(e.contains("nested deeper"), "{e}");
let ok = from_xml(&nested(100)).unwrap();
assert!(serde_json::from_str::<Value>(&ok).is_ok());
let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1));
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::from_str::<Value>(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v);
}
}

View File

@ -22,7 +22,10 @@
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs
//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`]
//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused
//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost};
use effectcraft_time::Tick;
use host::{Own, Req, Resolver, prop_dims, value_resp};
use runtime::{Begin, FRAMES, Frame, Run};
use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run};
/// Maximum nesting of expressions that read other expressions (cycles stop here).
pub const MAX_DEPTH: usize = 16;
/// Maximum script re-runs while answering host requests.
const MAX_PASSES: usize = 2000;
/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads
/// that fan out (an expression reading two values whose expressions each read two more…) grow
/// exponentially with [`MAX_DEPTH`] and would hang the frame.
pub const MAX_RUNS: usize = 20_000;
/// Longest one outermost evaluation may take (its script runs and the expressions it reads),
/// checked between runs and while a script runs: endless or very long loops, and a script that
/// loops a while and then reads many values (re-running the loop once per value), stop here.
/// Native builds only (the web build has no clock here; loops stop at the loop limit there).
pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5);
/// Start the work budget of an outermost evaluation.
fn budget_start() {
RUNS.with(|r| r.set(0));
#[cfg(not(target_arch = "wasm32"))]
runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now())));
}
/// An error once the outermost evaluation has used up its runs or its time.
fn budget_left() -> Result<(), String> {
if RUNS.with(|r| r.get()) >= MAX_RUNS {
return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into());
}
if runtime::out_of_time() {
return Err(runtime::TIMED_OUT.into());
}
Ok(())
}
/// The expression engine (stateless; runtimes are per thread).
#[derive(Clone, Copy, Debug, Default)]
@ -111,10 +141,23 @@ fn evaluate_out(
text_sel: [f64; 2],
selector: [f64; 3],
) -> Result<runtime::Out, String> {
let depth = FRAMES.with(|f| f.borrow().len());
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
let (depth, cycle) = FRAMES.with(|f| {
let f = f.borrow();
(f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner)))
});
if depth == 0 {
budget_start();
}
if cycle {
// Needs its own result: fails at once (the reader falls back to the keyframed value)
// instead of nesting to `MAX_DEPTH` once per read on the way.
return Err("Error: circular expression reference (the expression needs its own value)".into());
}
if depth >= MAX_DEPTH {
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
budget_left()?;
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
@ -133,7 +176,7 @@ fn evaluate_out(
selector,
};
// Answer the requests nearly every expression makes up front.
let mut frame = Frame::default();
let mut frame = Frame { owner: Some(owner), ..Frame::default() };
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
let r = resolver.resolve(&req);
frame.memo.insert(req, r);
@ -141,6 +184,8 @@ fn evaluate_out(
FRAMES.with(|f| f.borrow_mut().push(frame));
let _guard = FrameGuard;
for _ in 0..MAX_PASSES {
budget_left()?;
RUNS.with(|r| r.set(r.get().saturating_add(1)));
match runtime::run(text, &begin) {
Run::Done(r) => return r,
Run::Pending => {

View File

@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 {
let i = x.floor();
let u = fade(x - i);
let i = i as i64;
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1)));
a + (b - a) * u
}
@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0;
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
@ -99,6 +99,18 @@ mod tests {
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
}
#[test]
fn huge_inputs_do_not_overflow() {
// Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time
// put the lattice index at `i64::MAX`, and its neighbour overflowed.
let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6);
assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0));
for x in [1e300, -1e300, 9.3e18, -9.3e18] {
assert!(noise1(5, x).is_finite());
assert!(noise3(x, -x, x).is_finite());
}
}
#[test]
fn wiggle_bounds() {
for i in 0..2000 {

View File

@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.body);
}
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
/// about 60 levels, so deeper expressions are rejected with an error instead.
/// The deepest nesting an expression may have, in levels of brackets, before and after the
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
pub const MAX_NESTING: usize = 32;
fn nesting(toks: &[Tok]) -> usize {
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
for t in toks {
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
depth += 1;
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
depth = depth.saturating_sub(1);
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
/// prefix operator, which cost the parser about as much or are never chained in real code).
const LEVEL: usize = 256;
/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments,
/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a
/// bracket: 256 of them in a row are allowed; boa overflows from about 750).
const HEAVY: usize = 32;
/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing,
/// the comma operator): the tree is built in a loop but compiled recursively (overflows from
/// about 3 800 links).
const LIGHT: usize = 6;
/// One bracket level of [`too_nested`]'s walk.
#[derive(Default)]
struct Level {
/// `[`: commas separate elements (a loop in the parser), not operands.
array: bool,
/// Cost of the chains of the current statement or element.
chain: usize,
/// Comma operators so far in the current statement.
commas: usize,
/// `else` branches in a row: `if … else if … else if …` nests even across `;`.
elses: usize,
/// The next token starts a statement.
fresh: bool,
}
impl Level {
fn boundary(&mut self) {
self.chain = 0;
self.commas = 0;
self.fresh = true;
}
}
const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="];
/// Can `t` end an expression statement (so a line break after it may end the statement)?
fn ends_value(t: &Tok) -> bool {
match t.kind {
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW),
Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
}
}
/// Can `t` only start a new statement after a value (automatic semicolon insertion)?
fn starts_statement(t: &Tok) -> bool {
match t.kind {
Kind::Num | Kind::Str => true,
Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]),
Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"),
Kind::Template | Kind::Regex => false,
}
}
/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups).
fn inner_depth(text: &str) -> usize {
let (mut d, mut max) = (0usize, 0usize);
for c in text.chars() {
match c {
'(' | '[' | '{' => d += 1,
')' | ']' | '}' => d = d.saturating_sub(1),
_ => {}
}
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
prefix = if is_prefix { prefix + 1 } else { 0 };
max = max.max(depth + prefix);
max = max.max(d);
}
max
}
/// The code of a template literal's `${…}` substitutions (the outermost ones).
fn substitutions(template: &str) -> Vec<String> {
let c: Vec<char> = template.chars().collect();
let mut out = vec![];
let mut i = 1;
while i < c.len() {
match c[i] {
'\\' => i += 2,
'$' if c.get(i + 1) == Some(&'{') => {
let start = i + 2;
let (mut j, mut depth) = (start, 0usize);
while j < c.len() {
match c[j] {
'{' => depth += 1,
'}' if depth == 0 => break,
'}' => depth -= 1,
'\'' | '"' => {
j = skip_string(&c, j);
continue;
}
'`' => {
j = skip_template(&c, j);
continue;
}
_ => {}
}
j += 1;
}
out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect());
i = j + 1;
}
_ => i += 1,
}
}
out
}
/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack
/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the
/// current statement (or array element) weighted by what each link costs the parser.
fn too_nested(toks: &[Tok]) -> bool {
too_nested_from(toks, 0)
}
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
let mut levels = vec![Level::default()];
let mut prefix = 0usize;
let mut prev: Option<&Tok> = None;
for t in toks {
let p = t.kind == Kind::Punct;
if let Some(l) = levels.last_mut() {
if t.nl && prev.is_some_and(ends_value) && starts_statement(t) {
l.boundary();
}
if l.fresh {
if !t.is_kw(&["else"]) {
l.elses = 0;
}
l.fresh = false;
}
}
let mut inner = 0;
if p && matches!(t.text.as_str(), "(" | "[" | "{") {
// A call or index is a link in its chain too (`f()()()…`).
if t.text != "{"
&& let Some(l) = levels.last_mut()
{
l.chain = l.chain.saturating_add(LIGHT);
}
levels.push(Level { array: t.text == "[", ..Level::default() });
} else if p && matches!(t.text.as_str(), ")" | "]" | "}") {
if levels.len() > 1 {
levels.pop();
}
} else if let Some(l) = levels.last_mut() {
if t.is(";") {
l.boundary();
} else if t.is(",") {
l.commas += usize::from(!l.array);
l.chain = l.commas.saturating_mul(LIGHT);
} else if t.is_kw(&["else"]) {
l.elses += 1;
} else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str())))
|| t.is_kw(&["if", "while", "for", "with", "do"])
{
l.chain = l.chain.saturating_add(HEAVY);
} else if p || t.is_kw(&["in", "instanceof"]) {
l.chain = l.chain.saturating_add(LIGHT);
} else if matches!(t.kind, Kind::Template | Kind::Regex) {
// A tagged template is a link like a call.
l.chain = l.chain.saturating_add(LIGHT);
inner = inner_depth(&t.text);
}
}
let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]);
prefix = if is_prefix { prefix + 1 } else { 0 };
let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base);
for l in &levels {
cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY));
}
if cost > MAX_NESTING * LEVEL {
return true;
}
// The code inside `${…}` is parsed like any other, one level deeper (the depth check
// above bounds this recursion).
if t.kind == Kind::Template
&& t.text.contains("${")
&& substitutions(&t.text)
.iter()
.any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL)))
{
return true;
}
prev = Some(t);
}
false
}
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
if too_nested(&toks) {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
if too_nested(&tokenize(&out).0) {
return Err(too_deep());
}
Ok(())
}
fn too_deep() -> String {
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
}
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
let (toks, tail) = tokenize(src);
if nesting(&toks) > MAX_NESTING {
if too_nested(&toks) {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
if nesting(&tokenize(&out).0) > MAX_NESTING {
if too_nested(&tokenize(&out).0) {
return throw_too_deep();
}
out
@ -673,4 +848,60 @@ mod tests {
assert!(crate::check_syntax(&ok).is_ok());
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
}
#[test]
fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() {
// Found by the expression fuzzer (#786): the parser also recurses on chains that need no
// bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links,
// aborted the app with a stack overflow; so did 75 nested template substitutions or
// regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter.
let chains: [(&str, &str); 19] = [
("x?x:", ""),
("()=>", ""),
("a=>", ""),
("if(x)", ""),
("if(x)x;else ", ""),
("x=", ""),
("x**", ""),
("x||", ""),
("x<", ""),
("x,", ""),
("x.y", ""),
("f()", ""),
("a:", ""),
("new ", ""),
("++", ""),
("`${", "}`"),
("s.replace(/(", ")/, '')"),
("`${x??", "}`"),
("{a:", "}"),
];
for (open, close) in chains {
for n in [2_000, 100_000] {
let src = open.repeat(n) + "1" + &close.repeat(n);
assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}");
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}");
}
}
// A chain inside one template substitution (the template is one token to the rewriter).
let src = format!("`${{{}1}}`", "x??".repeat(100_000));
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
}
#[test]
fn long_real_expressions_still_parse() {
let lines = |n: usize, line: &str| vec![line; n].join("\n");
let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::<String>() + "0";
let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::<Vec<_>>().join(", "));
let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::<Vec<_>>().join(", "));
let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::<Vec<_>>().join(", "));
let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx";
let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny";
let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + ");
let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string();
for src in [else_ifs, numbers, points, object, program, ifs, chained, template] {
assert!(check_nesting(&src).is_ok(), "{}", &src[..80]);
assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]);
}
}
}

View File

@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js");
/// Message of the error thrown when a request isn't answered yet (never shown to users).
const PENDING: &str = "\u{1}effectcraft: pending host request";
const MAX_SCRIPTS: usize = 4096;
/// Loop iterations one script run may make (per function call): a run re-executes once per
/// property it reads, so this bounds the time of every pass.
pub const LOOP_LIMIT: u64 = 1_000_000;
/// The error of an evaluation that ran out of time.
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
const STACK_LIMIT: &str = "maximum stack size";
@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size";
pub struct Frame {
pub memo: HashMap<Req, Resp>,
pub misses: Vec<Req>,
/// What this frame evaluates: an evaluation that needs itself is a cycle.
pub owner: Option<Owner>,
}
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Owner {
pub comp: u64,
pub layer: u64,
pub uid: u64,
pub time: effectcraft_time::Tick,
pub text: String,
}
thread_local! {
@ -32,6 +49,14 @@ thread_local! {
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
/// Evaluation stack (nested when one expression reads another's value).
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
/// Script runs since the outermost evaluation on this thread began (nested ones included).
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(target_arch = "wasm32"))]
thread_local! {
/// When the outermost evaluation on this thread began.
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
}
struct Runtime {
@ -209,7 +234,7 @@ impl Runtime {
fn new() -> Result<Runtime, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(10_000_000);
limits.set_loop_iteration_limit(LOOP_LIMIT);
limits.set_recursion_limit(400);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
(js_string!("__h"), 6, native_host),
@ -270,7 +295,11 @@ impl Runtime {
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
}
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
let result = match evaluate_in_time(&script, ctx) {
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
// Abandoned mid-run: the context is unusable now (see `run`).
None => return Run::Done(Err(TIMED_OUT.into())),
};
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
if pending {
return Run::Pending;
@ -282,6 +311,54 @@ impl Runtime {
}
}
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
/// the script and the functions it calls (not inside callbacks of native functions such as
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
loop {
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
return Some(r);
}
if out_of_time() {
return None;
}
}
}
#[cfg(test)]
thread_local! {
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
/// loaded machine cannot reach the clock first).
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
}
#[cfg(not(target_arch = "wasm32"))]
fn max_time() -> std::time::Duration {
#[cfg(test)]
{
TIME_LIMIT.with(|t| t.get())
}
#[cfg(not(test))]
{
crate::MAX_TIME
}
}
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
pub fn out_of_time() -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// Copy a JS value out of the heap.
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
if v.is_undefined() {
@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run {
Err(e) => return Run::Done(Err(e.clone())),
};
match out {
Run::Done(Err(e)) if e == TIMED_OUT => {
// Abandoned mid-run: start the next evaluation on a fresh context.
if let Some(old) = r.take() {
drop(ManuallyDrop::into_inner(old));
}
return Run::Done(Err(e));
}
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
if let Some(old) = r.take() {
@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
Value::Str(_) => Value::Str(text(out)),
Value::Path(_) => match out {
Out::Path { points, ins, outs, closed } => {
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
// the rasterizer must never see.
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
return Err("Error: path points must be finite numbers".into());
}
let n = points.len();
let fit = |v: &[[f64; 2]]| {
let mut v = v.to_vec();

View File

@ -1,5 +1,7 @@
use std::time::Instant;
mod fuzz;
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
@ -383,11 +385,87 @@ fn circular_references_terminate() {
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
assert!(start.elapsed().as_secs_f64() < 10.0);
// The cycle bottoms out at the depth limit and falls back to keyframed values.
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
// The cycle is caught when A needs itself again, and falls back to keyframed values.
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
}
#[test]
fn an_expression_needing_its_own_value_fails_fast() {
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
let mut f = fx();
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
}
#[test]
fn fanning_out_reads_stop_at_a_budget() {
// Two layers reading each other twice at other times: no cycle (the times differ), but
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
let mut f = fx();
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
f.set_expr(f.a, ROT, &text("B"));
f.set_expr(f.b, ROT, &text("A"));
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
// the evaluation before the run count does, with a different error.
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
let start = Instant::now();
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
assert!(r.unwrap_err().contains("too many"));
// The budget is per outermost evaluation: the next one starts afresh.
assert_close!(f.num("time * 2", 1.0), 2.0);
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
}
#[test]
fn loops_stop_at_a_million_iterations() {
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
// per run (seconds), once more for every property the script reads after it.
let f = fx();
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
assert!(f.err("for (;;) {}").contains("iteration"));
}
#[test]
fn a_long_running_script_stops_at_the_time_budget() {
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
let f = fx();
let start = Instant::now();
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
assert!(e.contains("too long"), "{e}");
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
// The thread's runtime starts afresh.
assert_close!(f.num("time * 2", 1.0), 2.0);
}
#[test]
fn a_path_with_non_numeric_points_is_an_error() {
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
// rasterizer's clipping assertion when the mask was drawn.
let mut f = fx();
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
f.set_expr(f.a, "masks/#1/path", text);
let host = Expressions;
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
r.expr = Some(&host);
r.comp_frame(f.cid, Tick::ZERO);
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
}
#[test]
fn errors_and_property_reads_keep_the_runtime_usable() {
// Errors thrown inside helpers, and the aborts every property read makes until the host
@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() {
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
// Generous bound so unoptimised CI builds pass; release is far below.
assert!(per_frame < 100.0, "{per_frame} ms/frame");
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
}
#[test]

View File

@ -0,0 +1,361 @@
//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an
//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation
//! (AGENTS.md "Never crash").
//!
//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`,
//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…)
//! are mutated character-wise (flips, deletions, duplications, splices with other seeds,
//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers
//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then
//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp,
//! read through another layer's expression and rendered in a small frame. Inputs run on a worker
//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a
//! hang is reported with the iteration that reproduces it instead of taking the run down, and a
//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1`
//! prints every input before it runs).
//!
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_`
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call.
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
use effectcraft_render::{NoFootage, RenderOpts, Renderer};
use super::{Fx, OPA, POS, ROT, fx, secs};
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
/// splitmix64: tiny, good enough for picking mutations.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// `0..n` (`0` when `n` is 0).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[self.below(xs.len())]
}
}
/// Real expressions, in the shapes people write them (and the test comp can answer them).
#[rustfmt::skip]
const SEEDS: &[&str] = &[
"time * 360",
"value",
"wiggle(2, 50)",
"wiggle(5, 20, 3, 0.5, time * 2)",
"temporalWiggle(2, 0.1)",
"loopOut('cycle')",
"loopOut('pingpong', 1)",
"loopIn('offset')",
"loopOutDuration('continue', 0.5)",
"valueAtTime(time - 0.5)",
"thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]",
"speedAtTime(0.5)",
"smooth(0.2, 5)",
"nearestKey(time).index",
"numKeys > 1 ? key(numKeys).value : value",
"thisComp.layer('A').transform.rotation.valueAtTime(0.25)",
"thisComp.layer(\"B\").transform.position[0] + 10",
"thisComp.layer(1).effect('Slider Control')('Slider') * 2",
"effect(1).param(1) + effect(1)(1)",
"comp('Other').layer(1).name",
"comp('Other').width / thisComp.height",
"thisLayer.index + thisComp.numLayers",
"mask('Mask 1').maskOpacity",
"mask('Mask 1').maskPath.points()[0]",
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
"marker.numKeys > 0 ? marker.key(1).time : 0",
"marker.nearestKey(time).comment",
"thisComp.marker.key(1).time",
"var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]",
"toComp([0, 0])",
"fromWorld(toWorld(anchorPoint))",
"lookAt([0, 0, 0], [100, 0, 100])",
"value + [10, -5]",
"[1, 2] * 2 - [0.5, 0.5] / 4",
"x = value; x += [1, 0]; x",
"linear(time, 0, 1, 0, 100)",
"ease(time, 0, 2, [0, 0], [100, 50])",
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
"clamp(value * 2, 0, 100)",
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
"normalize([3, 4])[0] * degreesToRadians(180)",
"seedRandom(5, true); random(100)",
"seedRandom(index, false); gaussRandom([10, 20])",
"noise(time) * 100",
"posterizeTime(12); wiggle(3, 30)",
"framesToTime(timeToFrames(time) + 1)",
"rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]",
"Math.sin(time * Math.PI * 2) * 50",
"Math.round(value / 10) * 10",
"var s = 0; for (var i = 0; i < 10; i++) { s += i; } s",
"function f(a) { return a * 2 } f(value)",
"var g = (a, b) => a - b; g(5, 3)",
"if (time > 1) { 100 } else { 0 }",
"try { thisComp.layer('Missing').name } catch (e) { 0 }",
"'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration",
"text.sourceText + ' world'",
"text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)",
"thisComp.layer('Title').text.sourceText.style.fontSize",
"`${thisLayer.name}: ${Math.floor(time)}`",
"createPath([[0, 0], [100, 0], [100, 100]], [], [], true)",
"var p = thisProperty; p.pointOnPath(0.5)",
"[thisComp.width, thisComp.height] / 2",
"thisComp.frameDuration * 10",
"inPoint + (outPoint - inPoint) * 0.5",
"transform.opacity * 0.5 + [0][0]",
];
/// What gets inserted: syntax, keywords, object-model names and odd characters.
#[rustfmt::skip]
const TOKENS: &[&str] = &[
"(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--",
"+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t",
"\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ",
"function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try",
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
];
/// Numbers swapped in for numeric literals.
#[rustfmt::skip]
const NUMBERS: &[&str] = &[
"0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993",
"1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff",
];
/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser.
#[rustfmt::skip]
const NESTS: &[(&str, &str)] = &[
("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""),
("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""),
("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""),
("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""),
("x.y", ""), ("f()", ""), ("x[0]", ""),
];
/// One mutated expression from the seeds, decided by the iteration's own generator.
fn mutate(r: &mut Rng) -> String {
let mut s: Vec<char> = r.pick(SEEDS).chars().collect();
if r.chance(4) {
// Splice two expressions together.
let other: Vec<char> = r.pick(SEEDS).chars().collect();
let at = r.below(s.len() + 1);
let glue: Vec<char> = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect();
s.splice(at..at, glue.into_iter().chain(other));
}
for _ in 0..1 + r.below(4) {
let len = s.len();
match r.below(10) {
0 if len > 0 => {
// A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD).
let mut b = s.iter().collect::<String>().into_bytes();
let i = r.below(b.len());
b[i] ^= 1 << r.below(8);
s = String::from_utf8_lossy(&b).chars().collect();
}
1 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(16)).min(len);
s.drain(a..e);
}
2 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(32)).min(len);
let chunk = s[a..e].to_vec();
let at = r.below(len + 1);
s.splice(at..at, chunk);
}
3 | 4 => {
let t: Vec<char> = r.pick(TOKENS).chars().collect();
let at = r.below(len + 1);
s.splice(at..at, t);
}
5 => {
// An extreme number in place of a numeric literal (or anywhere).
let starts: Vec<usize> = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect();
let n: Vec<char> = r.pick(NUMBERS).chars().collect();
if starts.is_empty() {
let at = r.below(len + 1);
s.splice(at..at, n);
} else {
let a = *r.pick(&starts);
let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len);
s.splice(a..e, n);
}
}
6 => {
// Deep nesting or a long chain, closed or not.
let (open, close) = *r.pick(NESTS);
let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]);
let at = r.below(len + 1);
let mut nest: Vec<char> = open.repeat(depth).chars().collect();
nest.push('1');
if !r.chance(4) {
nest.extend(close.repeat(depth).chars());
}
s.splice(at..at, nest);
}
7 if len > 0 => s.truncate(r.below(len)),
8 => {
// A whole statement in front or behind.
let other: Vec<char> = r.pick(SEEDS).chars().collect();
if r.chance(2) {
s.extend(";\n".chars().chain(other));
} else {
s.splice(0..0, other.into_iter().chain(";\n".chars()));
}
}
_ => {}
}
}
s.into_iter().collect()
}
/// Where an input is evaluated: a scalar, a vector, a text and a path property.
const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)];
const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6];
/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression
/// and render a small frame with it. `Err` only for what must never happen: an `internal error`
/// (a panic caught by a guard).
fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> {
internal(&check_syntax(text))?;
let layers = [f.a, f.b, f.title, f.shape];
let (li, path) = TARGETS[target % TARGETS.len()];
let layer = layers[li];
internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?;
// As the property's own expression, read by B's rotation and drawn by the renderer.
f.set_expr(layer, path, text);
f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1));
internal(&eval_property(&f.p, f.cid, layer, path, t))?;
internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?;
let host = Expressions;
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() });
r.expr = Some(&host);
r.comp_frame(f.cid, secs(t));
// Leave the fixture as it was.
let l = f.layer_mut(layer);
if let Some(p) = l.props.prop_mut(path) {
p.expr = None;
}
if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) {
p.expr = None;
}
Ok(())
}
fn internal<T>(r: &Result<T, String>) -> Result<(), String> {
match r {
Err(e) if e.contains("internal error") => Err(e.clone()),
_ => Ok(()),
}
}
struct Job {
text: String,
target: usize,
t: f64,
}
/// A worker thread that keeps its expression runtime between inputs, like a render thread.
struct Worker {
jobs: Sender<Job>,
results: Receiver<Result<(), String>>,
}
impl Worker {
fn start() -> Result<Worker, String> {
let (jobs, rx) = channel::<Job>();
let (tx, results) = channel();
std::thread::Builder::new()
.name("fuzz-expr".into())
.stack_size(2 << 20)
.spawn(move || {
let mut f = fx();
for job in rx {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t)));
let r = r.unwrap_or_else(|p| {
Err(format!(
"panic: {}",
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()
))
});
let failed = r.is_err();
if tx.send(r).is_err() || failed {
break; // after a panic the fixture and runtime may be half-updated: start over
}
}
})
.map_err(|e| format!("couldn't start the fuzz thread: {e}"))?;
Ok(Worker { jobs, results })
}
/// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped).
fn run(&self, job: Job, deadline: Duration) -> Result<(), String> {
self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?;
self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
}
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
#[test]
fn fuzz_expressions_never_crash() {
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2);
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500);
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5));
let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0;
// Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times.
let deadline = crate::MAX_TIME * 24;
let mut worker = Worker::start().unwrap();
// The unmutated seeds evaluate everywhere without crashing.
for (i, s) in SEEDS.iter().enumerate() {
assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}");
}
let began = Instant::now();
let mut failures = vec![];
let mut ran = 0;
for i in start..start + iters {
if began.elapsed() > budget {
break;
}
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
let text = mutate(&mut r);
let target = r.below(TARGETS.len());
let t = *r.pick(TIMES);
if verbose {
eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::<String>());
}
ran += 1;
if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) {
let head: String = text.chars().take(200).collect();
failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len()));
if failures.len() >= 5 {
break;
}
worker = Worker::start().unwrap();
}
}
assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed());
}

View File

@ -145,6 +145,21 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
let out = f(Buf { img, offset: b.offset, scale: b.scale });
self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale });
}
/// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no
/// readback. A region the device can't allocate leaves the buffer as it is.
fn cover(&mut self, size: [f64; 2]) {
let Some(b) = self.b.take() else { return };
let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
Some((dx, dy, w, h)) if self.e.g.fits(w, h) => {
let img = self.e.image(w, h);
self.e.copy_into(&b.img, &img, dx, dy);
GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale }
}
_ => b,
};
self.b = Some(out);
}
}
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {

View File

@ -45,11 +45,17 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
let fd = 1.0 / ctx.fps();
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
}
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
// cannot be built (no host) declines to the CPU effect.
// Pixel Motion Blur synthesizes along measured motion vectors (#535); without a plan (no
// host to read neighbouring frames) it averages the shutter's frames, as the CPU effect does.
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
Some(plan) => render_plan(e, &plan),
None => None,
None => {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(e, ctx, b, &times)
}
},
"ec.time.timewarp" => timewarp(e, ctx, b),
_ => None,

View File

@ -665,7 +665,7 @@ fn fxn_fractal(@builtin(global_invocation_id) gid: vec3<u32>) {
r = blend_pixel(P.u[2].x, orig, s, 0.5);
}
let bo = P.f[1].w;
textureStore(out, p, orig * bo + r * (1.0 - bo));
textureStore(out, p, orig * bo + r * orig.a * (1.0 - bo));
}
// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs)

View File

@ -865,13 +865,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
val = 1.0 - val;
}
let op = P.f[2].z;
var fx = vec4<f32>(vec3<f32>(val) * op, op);
if (P.u[1].z == 1u) {
// Normal: the noise over the layer at Opacity, then Blend With Original.
let over = vec4<f32>(vec3<f32>(val) * op, op) + px * (1.0 - op);
px = px * P.f[2].y + over * (1.0 - P.f[2].y);
} else {
px = put(px, vec3<f32>(val), op, P.f[2].y);
// Normal: the noise over the layer at Opacity.
fx = fx + px * (1.0 - op);
}
// Inside the layer's alpha (its masks), then Blend With Original.
px = px * P.f[2].y + fx * px.w * (1.0 - P.f[2].y);
}
default: {}
}

View File

@ -287,7 +287,9 @@ impl SpatialSeg {
/// along curved paths instead of piecewise linear per cell (#456).
fn at_length(&self, s: f64) -> [f64; 3] {
let len = self.length();
if len <= 0.0 {
// A path whose length overflowed (huge coordinates) or is NaN has no arc-length table to
// walk: `clamp` would panic on a NaN bound.
if !(len > 0.0 && len.is_finite()) {
return self.p[0];
}
let s = s.clamp(0.0, len);
@ -878,6 +880,30 @@ mod tests {
assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0]));
}
/// #880: a motion path whose arc length overflows (huge coordinates, as the Position field
/// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app.
#[test]
fn overflowing_or_nan_motion_paths_evaluate() {
let paths = [
[[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]],
[[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]],
[[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]],
];
for path in paths {
let mut keys: Vec<Keyframe> = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect();
for eased in [false, true] {
if eased {
keys = keys.into_iter().map(Keyframe::eased).collect();
}
for f in 0..=40 {
let t = s(f as f64 * 0.1);
assert!(evaluate(&keys, t, true).is_some());
let _ = speed(&keys, t, true);
}
}
}
}
#[test]
fn spatial_auto_bezier_tangents_follow_after_effects() {
// Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each

View File

@ -20,6 +20,7 @@ effectcraft-pdf = { workspace = true }
effectcraft-model = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-isobmff = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-time = { workspace = true }

View File

@ -114,6 +114,32 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
img
}
/// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]:
/// clockwise quarter turns; anything else leaves it as decoded).
pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image {
use image::metadata::Orientation;
let (w, h) = (img.width as usize, img.height as usize);
if img.data.len() != w * h || w == 0 || h == 0 {
return img;
}
// The source pixel shown at (x, y) of the turned picture.
let (tw, th, from): (usize, usize, fn(usize, usize, usize, usize) -> usize) = match turn {
Orientation::Rotate90 => (h, w, |x, y, w, h| (h - 1 - x) * w + y),
Orientation::Rotate180 => (w, h, |x, y, w, h| (h - 1 - y) * w + (w - 1 - x)),
Orientation::Rotate270 => (h, w, |x, y, w, _| x * w + (w - 1 - y)),
_ => return img,
};
let mut data = vec![[0.0; 4]; tw * th];
data.par_chunks_mut(tw).enumerate().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
if let Some(s) = img.data.get(from(x, y, w, h)) {
*px = *s;
}
}
});
Image { width: tw as u32, height: th as u32, data }
}
/// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way:
/// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels.
/// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma,

133
crates/media/src/downmix.rs Normal file
View File

@ -0,0 +1,133 @@
//! Stereo downmix of multichannel audio, shared by preview, waveform and export mixdown (they
//! all read footage audio through `MediaPool::audio_samples`).
//!
//! The matrix is the usual ITU-R BS.775 one: front L/R at unity, the centre and every surround
//! or back channel at −3 dB (1/√2) into both sides' own side (centre into both), the low
//! frequency effects channel left out (as ffmpeg's and swresample's default, `lfe_mix_level` 0;
//! not verified against Adobe's documentation, which does not state it). A back-centre channel
//! reaches both sides at −6 dB. The whole matrix is then scaled by one fixed gain, the reciprocal
//! of the largest row sum (ffmpeg's default normalisation), so that full-scale input can never
//! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and
//! stereo is untouched.
//!
//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM,
//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs,
//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC
//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables.
//! More than 8 channels carry no known layout: only the first two (front L/R) are used.
use std::f32::consts::FRAC_1_SQRT_2;
/// A source channel's contribution to the left and right outputs, before normalisation.
type Pair = [f32; 2];
const FL: Pair = [1.0, 0.0];
const FR: Pair = [0.0, 1.0];
const FC: Pair = [FRAC_1_SQRT_2, FRAC_1_SQRT_2];
const LFE: Pair = [0.0, 0.0];
const SL: Pair = [FRAC_1_SQRT_2, 0.0];
const SR: Pair = [0.0, FRAC_1_SQRT_2];
const CS: Pair = [0.5, 0.5];
/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised.
/// `aac` selects AAC's native channel order. Empty for zero channels.
pub fn stereo_gains(count: usize, aac: bool) -> Vec<Pair> {
let raw: &[Pair] = match (count, aac) {
(0, _) => &[],
(1, _) => &[[1.0, 1.0]],
(2, _) => &[FL, FR],
(3, false) => &[FL, FR, FC],
(3, true) => &[FC, FL, FR],
(4, false) => &[FL, FR, SL, SR],
(4, true) => &[FC, FL, FR, CS],
(5, false) => &[FL, FR, FC, SL, SR],
(5, true) => &[FC, FL, FR, SL, SR],
(6, false) => &[FL, FR, FC, LFE, SL, SR],
(6, true) => &[FC, FL, FR, SL, SR, LFE],
(7, false) => &[FL, FR, FC, LFE, CS, SL, SR],
(7, true) => &[FC, FL, FR, SL, SR, CS, LFE],
(8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR],
(8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE],
_ => &[FL, FR],
};
let mut out = raw.to_vec();
// Channels beyond a known layout (more than 8) are silent.
out.resize(count, [0.0, 0.0]);
let peak = out.iter().map(|g| g[0]).sum::<f32>().max(out.iter().map(|g| g[1]).sum::<f32>());
// Mono is deliberately left at unity on both sides; so is anything already within range.
if count > 2 && peak > 1.0 {
for g in &mut out {
g[0] /= peak;
g[1] /= peak;
}
}
out
}
/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a
/// channel is too short for count as silence, extra frames in `out` stay as they are.
pub fn downmix_into(channels: &[Vec<f32>], aac: bool, out: &mut [f32]) {
let gains = stereo_gains(channels.len(), aac);
for (ch, g) in channels.iter().zip(&gains) {
if g[0] == 0.0 && g[1] == 0.0 {
continue;
}
for (o, v) in out.as_chunks_mut::<2>().0.iter_mut().zip(ch) {
o[0] += v * g[0];
o[1] += v * g[1];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_channel_count_has_a_bounded_defined_matrix() {
for aac in [false, true] {
for n in 0..40usize {
let g = stereo_gains(n, aac);
assert_eq!(g.len(), n);
let (l, r) = (g.iter().map(|p| p[0]).sum::<f32>(), g.iter().map(|p| p[1]).sum::<f32>());
if n > 2 {
assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}");
assert!(l > 0.0 && r > 0.0);
}
}
}
}
#[test]
fn five_one_matches_the_bs775_matrix() {
let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2);
let g = stereo_gains(6, false);
assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0);
assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6);
assert_eq!(g[3], [0.0, 0.0], "LFE is left out");
assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0);
assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0);
// AAC order: the centre is first, LFE last
let a = stereo_gains(6, true);
assert_eq!((a[0], a[5]), (g[2], g[3]));
assert_eq!((a[1], a[2]), (g[0], g[1]));
}
#[test]
fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() {
assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]);
assert_eq!(stereo_gains(2, true), vec![FL, FR]);
let g = stereo_gains(12, false);
assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0]));
}
#[test]
fn ragged_and_empty_input_never_panics() {
let mut out = vec![0.0; 8];
downmix_into(&[], false, &mut out);
downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out);
assert!(out.iter().all(|v| v.is_finite()));
let mut none: [f32; 0] = [];
downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none);
}
}

View File

@ -3,11 +3,12 @@
//! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame
//! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the
//! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time.
//! Animated WebP ([`crate::webp_anim`]) shares [`Anim`] and this decode path.
use std::io::Cursor;
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use effectcraft_time::{FrameRate, Tick};
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder};
use crate::{MediaError, Result};
@ -15,19 +16,20 @@ use crate::{MediaError, Result};
/// The `Footage::codec` of an animated GIF.
pub(crate) const CODEC: &str = "GIF";
/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab,
/// whose wasm32 heap is 4 GiB at most and shared with everything else).
/// Decoded frames of one GIF or animated WebP are kept as 8-bit RGBA, up to this many bytes (less
/// in a browser tab, whose wasm32 heap is 4 GiB at most and shared with everything else).
#[cfg(not(target_arch = "wasm32"))]
const MAX_BYTES: usize = 1 << 30;
pub(crate) const MAX_BYTES: usize = 1 << 30;
#[cfg(target_arch = "wasm32")]
const MAX_BYTES: usize = 256 << 20;
pub(crate) const MAX_BYTES: usize = 256 << 20;
/// Frames read from a GIF's headers when probing.
const MAX_FRAMES: usize = 100_000;
/// Frames read from a GIF's or animated WebP's headers when probing.
pub(crate) const MAX_FRAMES: usize = 100_000;
/// Whether `f` is an animated GIF imported by [`probe`].
pub(crate) fn is_gif(f: &Footage) -> bool {
f.kind == FootageKind::Video && f.codec == CODEC
/// Whether `f` is an animated GIF or WebP imported by [`probe`] or [`crate::webp_anim::probe`]:
/// its frames come from an [`Anim`].
pub(crate) fn is_animated(f: &Footage) -> bool {
f.kind == FootageKind::Video && (f.codec == CODEC || f.codec == crate::webp_anim::CODEC)
}
/// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders
@ -36,7 +38,7 @@ fn delay_cs(raw: u32) -> u32 {
if raw < 2 { 10 } else { raw }
}
fn gcd(a: u32, b: u32) -> u32 {
pub(crate) fn gcd(a: u32, b: u32) -> u32 {
if b == 0 { a } else { gcd(b, a % b) }
}
@ -73,19 +75,27 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option<Footage> {
})
}
/// Every frame of a GIF, composited onto the full canvas.
/// Every frame of a GIF or animated WebP, composited onto the full canvas.
pub(crate) struct Anim {
frames: Vec<DynamicImage>,
/// When each frame starts, in hundredths of a second.
starts: Vec<u64>,
/// When each frame starts (ascending, one per frame).
starts: Vec<Tick>,
}
impl Anim {
/// Decode all frames. A damaged tail keeps the frames before it.
/// Frames and the times they start at (one each, ascending); `None` when there are none.
pub(crate) fn new(frames: Vec<DynamicImage>, starts: Vec<Tick>) -> Option<Anim> {
(!frames.is_empty() && frames.len() == starts.len()).then_some(Anim { frames, starts })
}
/// Decode all frames of a GIF or animated WebP. A damaged tail keeps the frames before it.
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
if crate::webp_anim::is_webp(bytes) {
return crate::webp_anim::load(bytes);
}
let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
let (mut frames, mut starts) = (Vec::new(), Vec::new());
let (mut at, mut size) = (0u64, 0usize);
let (mut at, mut size) = (0i64, 0usize);
for frame in dec.into_frames() {
let frame = match frame {
Ok(f) => f,
@ -102,21 +112,17 @@ impl Anim {
if size > MAX_BYTES {
return Err(MediaError::Unsupported("GIF is too large to hold in memory".into()));
}
starts.push(at);
at = at.saturating_add(u64::from(cs));
starts.push(Tick::from_units(at, 100));
at = at.saturating_add(i64::from(cs));
frames.push(DynamicImage::ImageRgba8(buf));
}
if frames.is_empty() {
return Err(MediaError::Decode("GIF: no frames".into()));
}
Ok(Anim { frames, starts })
Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("GIF: no frames".into()))
}
/// The frame shown at source time `t` (the last one past the end).
/// The frame shown at source time `t` (the first one before the start, the last one past the
/// end).
pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> {
let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128;
let cs = u64::try_from(cs).unwrap_or(u64::MAX);
let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1);
let i = self.starts.partition_point(|&s| s <= t).saturating_sub(1);
self.frames.get(i).or(self.frames.last())
}
}

View File

@ -40,6 +40,12 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
}
/// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles.
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool {
effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes)
}
/// Footage for a Photoshop or vector file (`None` for other formats).
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
if effectcraft_psd::is_psd(bytes) {
@ -81,7 +87,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?
} else {
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let img = crate::probe::load_oriented(data, None).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
let rgba = img.to_rgba32f();
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
};

View File

@ -16,6 +16,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod convert;
pub mod downmix;
mod dpx;
pub mod exr_channels;
mod gif_anim;
@ -23,6 +24,8 @@ mod layered;
pub use layered::vector_doc;
mod pool;
mod probe;
mod stream;
mod webp_anim;
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model};
@ -36,6 +39,9 @@ pub enum MediaError {
Unsupported(String),
#[error("decode error: {0}")]
Decode(String),
/// The read was called off before it finished (see [`effectcraft_render::FootageSource::cancellable`]).
#[error("cancelled")]
Cancelled,
}
pub type Result<T> = std::result::Result<T, MediaError>;
@ -46,6 +52,7 @@ impl From<filmcraft_media::MediaError> for MediaError {
match e {
F::Unsupported(s) => MediaError::Unsupported(s),
F::Io(s) | F::Offline(s) => MediaError::Io(s),
F::Cancelled => MediaError::Cancelled,
other => MediaError::Decode(other.to_string()),
}
}

View File

@ -9,8 +9,9 @@ use effectcraft_raster::{Image, Px};
use effectcraft_render::{FloatCurve, FootageSource};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use filmcraft_media::{FrameRequest, SharedSource};
use image::metadata::Orientation;
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled};
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned};
use crate::gif_anim::{self, Anim};
use crate::{MediaError, Result};
@ -170,9 +171,10 @@ struct CacheState {
last: HashMap<Arc<str>, i64>,
}
/// A movie/audio source by path, opened by the first caller while the others wait for it
/// A movie/audio source by path and the turn its picture is shown with
/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it
/// (`None`: failed to open; not retried until `forget`).
type SourceSlot = Arc<OnceLock<Option<SharedSource>>>;
type SourceSlot = Arc<OnceLock<Option<(SharedSource, Orientation)>>>;
/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load).
type GifSlot = Arc<OnceLock<std::result::Result<Arc<Anim>, String>>>;
@ -186,7 +188,7 @@ struct Inner {
files: Mutex<HashMap<String, Arc<[u8]>>>,
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`).
models: Mutex<HashMap<String, Option<Arc<effectcraft_model::Model>>>>,
/// Decoded animated GIFs by path, loaded by the first caller while the others wait (an
/// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an
/// `Err` is not retried until `forget`).
gifs: Mutex<HashMap<Arc<str>, GifSlot>>,
cache: Mutex<CacheState>,
@ -206,6 +208,9 @@ struct Inner {
/// audio is read from the decoder instead of trying again on every read.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
conform_failed: Mutex<HashSet<std::path::PathBuf>>,
/// Movies that failed to open or decode (path to a readable reason; see
/// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not.
problems: Mutex<HashMap<Arc<str>, String>>,
}
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
@ -275,6 +280,7 @@ impl MediaPool {
conform: Mutex::default(),
conforming: Mutex::default(),
conform_failed: Mutex::default(),
problems: Mutex::default(),
}),
}
}
@ -322,6 +328,7 @@ impl MediaPool {
pub fn clear(&self) {
self.clear_frames();
lock(&self.inner.sources).clear();
lock(&self.inner.problems).clear();
}
/// Forget a file (after it changed on disk, or to retry a file that failed to open).
@ -329,6 +336,7 @@ impl MediaPool {
lock(&self.inner.sources).remove(path);
lock(&self.inner.models).remove(path);
lock(&self.inner.gifs).remove(path);
lock(&self.inner.problems).remove(path);
let mut c = lock(&self.inner.cache);
let keys: Vec<Key> = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect();
for k in keys {
@ -357,8 +365,13 @@ impl MediaPool {
}
match Inner::frame_at(&self.inner, footage, t, true, size, float) {
Ok(img) => Some(img),
// nobody wants the frame any more (`cancellable`): not an error
Err(MediaError::Cancelled) => None,
// (a movie's failure is logged once per file, when it is recorded)
Err(e) => {
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
if lock(&self.inner.problems).get(footage.path.as_str()).is_none() {
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
}
None
}
}
@ -450,8 +463,8 @@ impl MediaPool {
}
/// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond
/// the first two are dropped. Silence where there is no audio (or before the media starts).
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; multichannel
/// sources are downmixed to stereo ([`crate::downmix`]). Silence where there is no audio (or before the media starts).
/// With a conformed audio folder, reads come from the conformed file once it is written.
pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
if !footage.has_audio || footage.missing || rate == 0 || frames == 0 {
@ -574,17 +587,34 @@ impl Inner {
/// it again: a preview's frame workers, audio feeder, conform and waveform threads each
/// decoded a long FLAC in full at once (#408).
fn source(&self, path: &Arc<str>) -> Option<SharedSource> {
self.opened(path).map(|(src, _)| src)
}
/// [`Self::source`] and the turn the movie's picture is shown with.
fn opened(&self, path: &Arc<str>) -> Option<(SharedSource, Orientation)> {
let slot = lock(&self.sources).entry(path.clone()).or_default().clone();
slot.get_or_init(|| {
self.opened.fetch_add(1, Ordering::Relaxed);
let opened = self.read(path).and_then(|bytes| {
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)
// Files on disk are read in place (the index, then samples on demand) rather than
// whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes.
let in_memory = lock(&self.files).contains_key(&**path);
let file = std::path::Path::new(&**path);
let in_place = if in_memory { None } else { crate::stream::open_source(file) };
let in_place = in_place.map(|src| {
let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h));
src.map(|src| (src, turn))
});
let opened = in_place.unwrap_or_else(|| {
self.read(path).and_then(|bytes| {
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let turn = crate::probe::video_turn(&bytes);
filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from)
})
});
match opened {
Ok(s) => Some(s),
Err(e) => {
log::warn!("media: cannot open {path}: {e}");
self.note_problem(path, "", &e);
None
}
}
@ -592,7 +622,21 @@ impl Inner {
.clone()
}
/// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the
/// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known)
/// and log it the first time. I/O errors (missing or unreadable file) are not decoder
/// problems and are left to the missing-footage handling.
fn note_problem(&self, path: &Arc<str>, codec: &str, e: &MediaError) {
// A decode called off for a newer frame (#329) says nothing about the file either.
if matches!(e, MediaError::Io(_) | MediaError::Cancelled) {
return;
}
let reason = describe_problem(codec, e);
if lock(&self.problems).insert(path.clone(), reason.clone()).is_none() {
log::warn!("media: {path}: {reason}");
}
}
/// The decoded frames of the animated GIF or WebP at `path`. The first caller decodes it, outside the
/// map's lock; callers asking for the same file meanwhile wait for that.
fn gif(&self, path: &Arc<str>) -> Result<Arc<Anim>> {
let slot = lock(&self.gifs).entry(path.clone()).or_default().clone();
@ -749,10 +793,7 @@ impl Inner {
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = match crate::still_format(path, &bytes) {
Some(fmt) => image::load_from_memory_with_format(&bytes, fmt),
None => image::load_from_memory(&bytes),
};
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
@ -760,25 +801,31 @@ impl Inner {
};
Ok(dynamic_to_image(&img, op))
}
Some(mt) if gif_anim::is_gif(footage) => {
Some(mt) if gif_anim::is_animated(footage) => {
let anim = self.gif(path)?;
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
Ok(dynamic_to_image(frame, op))
}
Some(mt) => {
let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?;
let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
let vf = src
.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0)))
.map_err(MediaError::from)
.inspect_err(|e| self.note_problem(path, &footage.codec, e))?;
// A movie shown turned a quarter (a phone held upright): the footage's size and
// the render's are the turned picture's, the decoded frame's are the file's.
let side = |(w, h): (u32, u32)| if crate::probe::swaps_sides(turn) { (h, w) } else { (w, h) };
// A reduced-resolution render's frame, made at its size (a frame of the footage's
// own size only, as the renderer resamples only those).
let (w, h) = loc.key.size;
let (w, h) = side(loc.key.size);
if w > 0
&& (vf.width, vf.height) == (footage.width, footage.height)
&& (vf.width, vf.height) == side((footage.width, footage.height))
&& let Some(img) = frame_to_image_scaled(&vf, w, h)
{
return Ok(img);
return Ok(turned(img, turn));
}
let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize);
Ok(frame_to_image_in(&vf, op, buf))
Ok(turned(frame_to_image_in(&vf, op, buf), turn))
}
}
}
@ -806,6 +853,12 @@ impl FootageSource for MediaPool {
fn cache_budget(&self) -> Option<usize> {
Some(self.budget())
}
/// A movie's decoder gives up on a seek (before it, and between samples while it decodes
/// forward from the keyframe) once `cancel` is set: a scrub's superseded frames no longer
/// hold the decoder the frame under the pointer waits for (#329).
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
filmcraft_media::cancel::with_cancel(cancel, f)
}
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.frame_sized(item, footage, t, None, FloatCurve::Srgb)
}
@ -816,6 +869,13 @@ impl FootageSource for MediaPool {
self.frame_sized(item, footage, t, size, float)
}
fn problem(&self, footage: &Footage) -> Option<String> {
if footage.missing || footage.kind != FootageKind::Video {
return None;
}
lock(&self.inner.problems).get(footage.path.as_str()).cloned()
}
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
if footage.missing || !effectcraft_render::is_vector_footage(footage) {
return None;
@ -846,7 +906,7 @@ impl FootageSource for MediaPool {
/// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which
/// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both
/// channels; silence before the start. An error where decoding fails.
/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails.
fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
let mut out = vec![0.0; frames * 2];
let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0);
@ -854,15 +914,28 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re
return Ok(out);
}
let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?;
let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) };
let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) };
for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) {
*o0 = *a;
*o1 = *b;
}
let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC"));
crate::downmix::downmix_into(&buf.channels, aac, dst);
Ok(out)
}
/// The one-line reason a movie shows no picture: its `codec` (the probe's, e.g. "H.264 (High
/// 4:2:2)") and what the decoder said, without the error wrappers it passed through.
fn describe_problem(codec: &str, e: &MediaError) -> String {
let mut detail = match e {
MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s) => s.as_str(),
MediaError::Cancelled => "the decode was called off",
};
let mut unsupported = matches!(e, MediaError::Unsupported(_));
while let Some(rest) = ["decode error: ", "unsupported media: ", "unsupported: ", "I/O error: "].iter().find_map(|p| detail.strip_prefix(p)) {
unsupported |= detail.starts_with("unsupported");
detail = rest;
}
let what = if codec.is_empty() { "This video" } else { codec };
if unsupported { format!("{what} isn't supported by the built-in decoder: {detail}") } else { format!("{what} can't be decoded: {detail}") }
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}

View File

@ -5,6 +5,7 @@ use std::sync::Arc;
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::{FrameRate, Tick};
use image::metadata::Orientation;
use crate::{MediaError, Result, ext_of, rate_from_fc};
@ -15,6 +16,13 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
/// (`effectcraft_engine::sequence`) and probes their first frame here.
pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
let path = path.as_ref();
// A movie is read in place (its index), not loaded whole.
if let Some(src) = crate::stream::probe_source(path) {
let mut f = footage_from_info(&path.to_string_lossy(), src?.info());
let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h));
turn_footage(&mut f, turn);
return Ok(f);
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
@ -77,11 +85,30 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
{
return Ok(f);
}
// So is an animated WebP; a still WebP stays a still.
if fmt == image::ImageFormat::WebP
&& let Some(f) = crate::webp_anim::probe(path, &bytes)
{
return Ok(f);
}
return probe_still_bytes(path, &bytes, fmt);
}
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
Ok(footage_from_info(path, src.info()))
let mut f = footage_from_info(path, src.info());
turn_footage(&mut f, turn);
Ok(f)
}
/// The size as displayed: a movie shot with the camera on its side trades width and height.
fn turn_footage(f: &mut Footage, turn: Orientation) {
if swaps_sides(turn) {
std::mem::swap(&mut f.width, &mut f.height);
if f.pixel_aspect > 0.0 {
f.pixel_aspect = 1.0 / f.pixel_aspect;
}
}
}
pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage {
@ -171,7 +198,12 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul
}
let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt);
let (w, h, has_alpha) = match reader.into_decoder() {
Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()),
Ok(mut dec) => {
// The size as displayed: an Exif orientation of a quarter turn swaps the sides.
let (w, h) = dec.dimensions();
let (w, h) = if swaps_sides(dec.orientation().unwrap_or(Orientation::NoTransforms)) { (h, w) } else { (w, h) };
(w, h, dec.color_type().has_alpha())
}
Err(e) => {
// A multi-layer OpenEXR file without an unnamed RGB layer.
let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten();
@ -181,3 +213,44 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul
};
Ok(still_footage(path, w, h, fmt, has_alpha))
}
/// Whether an Exif orientation turns the picture a quarter turn (its width and height trade places).
pub(crate) fn swaps_sides(o: Orientation) -> bool {
matches!(o, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH)
}
/// Decode a still the way it is meant to be seen: its Exif orientation (a camera held sideways, a
/// mirrored scan) is applied to the pixels, as image viewers do. A file without
/// the tag, or with a broken one, decodes as stored. `fmt`: the format when the file's extension
/// decides it (Targa has no signature to guess from), else guessed from the bytes.
pub(crate) fn load_oriented(bytes: &[u8], fmt: Option<image::ImageFormat>) -> image::ImageResult<image::DynamicImage> {
use image::ImageDecoder;
let reader = match fmt {
Some(fmt) => image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt),
None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?,
};
let mut dec = reader.into_decoder()?;
let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms);
let mut img = image::DynamicImage::from_decoder(dec)?;
img.apply_orientation(orientation);
Ok(img)
}
/// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and
/// says so in its video track's header): the quarter turn nearest the track's rotation. Only the
/// rotation counts; any mirroring, scaling or offset in the header is ignored, and other
/// containers have none.
pub(crate) fn video_turn(bytes: &[u8]) -> Orientation {
let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms };
let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else {
return Orientation::NoTransforms;
};
// [a b u; c d v; x y w]: (a, b) is where the picture's x axis points.
let (a, b) = (m[0] as i64, m[1] as i64);
match (a.abs() >= b.abs(), a >= 0, b >= 0) {
(true, true, _) => Orientation::NoTransforms,
(true, false, _) => Orientation::Rotate180,
(false, _, true) => Orientation::Rotate90,
(false, _, false) => Orientation::Rotate270,
}
}

162
crates/media/src/stream.rs Normal file
View File

@ -0,0 +1,162 @@
//! Movie and audio files read in place: the container's index now, single samples on demand.
//!
//! Reading a file whole before opening it made a clip cost its full size in memory (twice while
//! probing: the file's bytes plus the demuxer's copy), so a 45-minute movie took gigabytes just to
//! import. FilmCraft's reader openers (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG-PS/TS) open from a
//! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file.
//! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before.
//! Builds without a native file system (wasm32) keep using in-memory bytes.
use std::path::Path;
use filmcraft_media::SharedSource;
use crate::Result;
/// A file on disk read in place: it stays open and only the requested ranges are read.
#[cfg(any(unix, windows))]
#[derive(Debug)]
pub(crate) struct FileReader {
file: std::fs::File,
len: u64,
}
#[cfg(any(unix, windows))]
impl FileReader {
pub(crate) fn open(path: &Path) -> std::io::Result<Self> {
let file = std::fs::File::open(path)?;
let m = file.metadata()?;
if !m.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display())));
}
Ok(Self { file, len: m.len() })
}
}
#[cfg(any(unix, windows))]
impl filmcraft_media::ByteReader for FileReader {
fn len(&self) -> u64 {
self.len
}
/// Positional reads: threads decoding different parts of the file share no cursor.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
}
#[cfg(windows)]
{
let (mut done, mut at) = (0usize, offset);
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")),
Ok(n) => {
done = done.saturating_add(n);
at = at.saturating_add(n as u64);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
}
}
#[cfg(any(unix, windows))]
fn name_of(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
}
/// Open a movie/audio file for the media pool, reading only what its container needs; files the
/// reader openers don't handle are read whole and opened from memory, exactly as before. `None`
/// when the file can't be opened in place (the caller reads it whole, which reports the error).
#[cfg(any(unix, windows))]
pub(crate) fn open_source(path: &Path) -> Option<Result<SharedSource>> {
let reader = FileReader::open(path).ok()?;
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers());
Some(opened.map_err(Into::into))
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn open_source(_path: &Path) -> Option<Result<SharedSource>> {
None
}
/// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display
/// matrix needs), without reading the samples: top-level box headers are walked with positional
/// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large.
#[cfg(any(unix, windows))]
pub(crate) fn movie_header(path: &Path) -> Option<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
const MAX_MOOV: u64 = 64 << 20;
let mut f = std::fs::File::open(path).ok()?;
let len = f.metadata().ok()?.len();
let mut out = Vec::new();
let mut pos = 0u64;
for _ in 0..4096 {
if pos.checked_add(8)? > len {
return None;
}
f.seek(SeekFrom::Start(pos)).ok()?;
let mut h = [0u8; 16];
f.read_exact(&mut h[..8]).ok()?;
let kind = [h[4], h[5], h[6], h[7]];
let mut size = u64::from(u32::from_be_bytes([h[0], h[1], h[2], h[3]]));
if size == 1 {
f.read_exact(&mut h[8..16]).ok()?;
size = u64::from_be_bytes([h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]]);
} else if size == 0 {
size = len - pos;
}
if size < 8 || pos.checked_add(size)? > len {
return None;
}
if kind == *b"ftyp" || kind == *b"moov" {
if size > MAX_MOOV {
return None;
}
f.seek(SeekFrom::Start(pos)).ok()?;
let mut b = vec![0u8; usize::try_from(size).ok()?];
f.read_exact(&mut b).ok()?;
out.extend_from_slice(&b);
if kind == *b"moov" {
return Some(out);
}
}
pos += size;
}
None
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn movie_header(_path: &Path) -> Option<Vec<u8>> {
None
}
/// Probing in place: the opened container when `path` is a movie/audio container a reader opener
/// handles and nothing probed before containers (stills, layered and vector documents, 3D models)
/// claims it. `None`: probe the whole file's bytes as before.
#[cfg(any(unix, windows))]
pub(crate) fn probe_source(path: &Path) -> Option<Result<SharedSource>> {
let ext = crate::ext_of(path);
if crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::MODEL_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
let reader = FileReader::open(path).ok()?;
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
let head = filmcraft_media::reader::read_range(&*reader, 0, filmcraft_media::reader::HEAD_LEN).ok()?;
let name = path.to_string_lossy();
if crate::layered::sniff(&name, &head) || image::guess_format(&head).is_ok() {
return None;
}
let name = name_of(path);
filmcraft_codecs::reader_openers().into_iter().find_map(|open| open(&name, &head, &reader)).map(|r| r.map_err(Into::into))
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn probe_source(_path: &Path) -> Option<Result<SharedSource>> {
None
}

View File

@ -0,0 +1,329 @@
//! Animated WebP: probed as movies and decoded whole into 8-bit frames, like animated GIF
//! ([`crate::gif_anim`], whose [`Anim`] holds the frames).
//!
//! An animated WebP is a RIFF file with a `VP8X` header whose animation flag is set and one `ANMF`
//! chunk per frame, each with its own duration in milliseconds. Evenly spaced frames get the rate
//! they are spaced at. Encoders round the frame times of a rate like 24 or 29.97 frames a second to
//! whole milliseconds (41, 42, 42, 41… ms), so delays that differ by at most one millisecond are
//! read as the standard rate whose frame times they round to; any other timing gets the rate that
//! every delay is a whole number of frames at (1000 / the greatest common divisor of the delays),
//! so it is kept exactly. A still WebP (no animation flag, or a single frame) stays a still.
use std::io::Cursor;
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::{FrameRate, Tick};
use image::{AnimationDecoder, DynamicImage, codecs::webp::WebPDecoder};
use crate::gif_anim::{Anim, MAX_BYTES, MAX_FRAMES, gcd};
use crate::{MediaError, Result};
/// The `Footage::codec` of an animated WebP.
pub(crate) const CODEC: &str = "WebP";
/// Whether `bytes` start like a WebP file.
pub(crate) fn is_webp(bytes: &[u8]) -> bool {
bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP")
}
/// What the chunk headers of an animated WebP say.
struct Header {
width: u32,
height: u32,
alpha: bool,
/// Each frame's delay in milliseconds, as stored.
delays: Vec<u32>,
}
fn u24(b: &[u8]) -> Option<u32> {
match b {
[a, b, c, ..] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16),
_ => None,
}
}
/// Read the `VP8X` header and the `ANMF` frame headers. `None` when the file isn't an animated
/// WebP. A truncated tail keeps the frames before it.
fn header(bytes: &[u8]) -> Option<Header> {
if !is_webp(bytes) {
return None;
}
let riff = bytes.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?;
let end = usize::try_from(riff).ok()?.saturating_add(8).min(bytes.len());
let (mut at, mut canvas, mut delays) = (12usize, None, Vec::new());
while delays.len() < MAX_FRAMES {
let Some(head) = bytes.get(at..at.saturating_add(8)).filter(|_| at.saturating_add(8) <= end) else { break };
let size = head.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?;
let size = usize::try_from(size).ok()?;
let body_end = at.saturating_add(8).saturating_add(size);
let Some(body) = bytes.get(at.saturating_add(8)..body_end).filter(|_| body_end <= end) else { break };
match head.get(0..4) {
Some(b"VP8X") => {
let flags = *body.first()?;
if flags & 0x02 == 0 {
// No animation flag: a still.
return None;
}
let w = u24(body.get(4..)?)?.saturating_add(1);
let h = u24(body.get(7..)?)?.saturating_add(1);
canvas = Some((w, h, flags & 0x10 != 0));
}
Some(b"ANMF") if body.len() >= 16 => delays.push(u24(body.get(12..)?)?),
_ => {}
}
at = body_end.saturating_add(size & 1);
}
let (width, height, alpha) = canvas?;
Some(Header { width, height, alpha, delays })
}
/// The delay of a frame in milliseconds. Browsers show delays of 10 ms or less (that encoders
/// write for "as fast as possible") as 100 ms, and so do we, as for GIF.
fn delay_ms(raw: u32) -> u32 {
if raw <= 10 { 100 } else { raw }
}
/// The footage rate, when each frame starts, and the duration, for frames of these delays (ms).
struct Timing {
rate: FrameRate,
starts: Vec<Tick>,
duration: Tick,
}
fn timing(delays: &[u32]) -> Timing {
let delays: Vec<u32> = delays.iter().map(|&d| delay_ms(d)).collect();
let mut starts_ms = Vec::with_capacity(delays.len());
let mut total = 0i64;
for &d in &delays {
starts_ms.push(total);
total = total.saturating_add(i64::from(d));
}
let n = i64::try_from(delays.len()).unwrap_or(i64::MAX);
let (min, max) = (delays.iter().copied().min().unwrap_or(0), delays.iter().copied().max().unwrap_or(0));
// Frame times of a standard rate rounded to milliseconds: that rate if every frame starts (and
// the last one ends) within a millisecond of it.
let fps = if total > 0 { n.saturating_mul(1000).saturating_add(total / 2) / total } else { 0 };
if max == min.saturating_add(1) && fps > 0 {
for rate in [FrameRate::new(fps, 1), FrameRate::new(fps.saturating_mul(1000), 1001)] {
// |start_ms - k * 1000 * den / num| <= 1, in integers.
let (num, den) = (i128::from(rate.num), i128::from(rate.den));
let fits = starts_ms.iter().copied().chain([total]).enumerate().all(|(k, ms)| {
let k = i128::try_from(k).unwrap_or(i128::MAX);
(i128::from(ms) * num - k * 1000 * den).abs() <= num
});
if fits {
let starts = (0..n).map(|k| rate.tick_of(k)).collect();
return Timing { rate, starts, duration: rate.tick_of(n) };
}
}
}
let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1);
Timing {
rate: FrameRate::new(1000, i64::from(step)),
starts: starts_ms.iter().map(|&ms| Tick::from_units(ms, 1000)).collect(),
duration: Tick::from_units(total, 1000),
}
}
/// Describe an animated WebP as a movie. `None` for a still WebP (no animation, or one frame) or
/// one whose headers can't be read.
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option<Footage> {
let h = header(bytes)?;
if h.delays.len() < 2 || h.width == 0 || h.height == 0 {
return None;
}
let t = timing(&h.delays);
Some(Footage {
path: path.to_string(),
kind: FootageKind::Video,
width: h.width,
height: h.height,
frame_rate: t.rate,
duration: t.duration,
has_video: true,
has_audio: false,
alpha: if h.alpha { AlphaMode::Straight } else { AlphaMode::Ignore },
codec: CODEC.to_string(),
..Default::default()
})
}
/// Decode every frame of an animated WebP, composited onto the full canvas. A damaged tail keeps
/// the frames before it; an animation too large to hold in memory is refused before decoding.
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
let h = header(bytes).ok_or_else(|| MediaError::Decode("WebP: not an animated WebP".into()))?;
let frame_bytes = usize::try_from(u64::from(h.width) * u64::from(h.height) * 4).unwrap_or(usize::MAX);
if frame_bytes.saturating_mul(h.delays.len()) > MAX_BYTES {
return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height)));
}
let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?;
let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize);
for frame in dec.into_frames() {
let frame = match frame {
Ok(f) => f,
Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("WebP: {e}"))),
Err(e) => {
log::warn!("media: WebP stops after {} frames: {e}", frames.len());
break;
}
};
let (numer, denom) = frame.delay().numer_denom_ms();
delays.push(numer.checked_div(denom).unwrap_or(0));
let buf = frame.into_buffer();
size = size.saturating_add(buf.as_raw().len());
if size > MAX_BYTES {
return Err(MediaError::Unsupported("animated WebP is too large to hold in memory".into()));
}
frames.push(DynamicImage::ImageRgba8(buf));
}
let starts = timing(&delays).starts;
Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("WebP: no frames".into()))
}
#[cfg(test)]
mod tests {
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::{FrameRate, Tick};
use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder};
use super::timing;
use crate::{MediaPool, probe_bytes};
fn chunk(fourcc: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = fourcc.to_vec();
out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
out.extend_from_slice(body);
if body.len() % 2 == 1 {
out.push(0);
}
out
}
fn u24(v: u32) -> [u8; 3] {
let b = v.to_le_bytes();
[b[0], b[1], b[2]]
}
/// The `VP8L` chunk of a lossless still WebP of a solid RGBA colour.
fn vp8l(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut still = Vec::new();
let px = rgba.repeat((w * h) as usize);
WebPEncoder::new_lossless(&mut still).write_image(&px, w, h, ExtendedColorType::Rgba8).unwrap();
let at = still.windows(4).position(|c| c == b"VP8L").unwrap();
still[at..].to_vec()
}
/// A `w`×`h` animated WebP with alpha, one solid full-canvas frame per `(rgba, delay ms)`.
fn webp(w: u32, h: u32, frames: &[([u8; 4], u32)]) -> Vec<u8> {
let mut vp8x = vec![0x12, 0, 0, 0];
vp8x.extend_from_slice(&u24(w - 1));
vp8x.extend_from_slice(&u24(h - 1));
let mut body = b"WEBP".to_vec();
body.extend(chunk(b"VP8X", &vp8x));
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]));
for (rgba, delay) in frames {
let mut anmf = Vec::new();
anmf.extend_from_slice(&[0; 6]);
anmf.extend_from_slice(&u24(w - 1));
anmf.extend_from_slice(&u24(h - 1));
anmf.extend_from_slice(&u24(*delay));
anmf.push(0b10); // no blending, no disposal
anmf.extend(vp8l(w, h, *rgba));
body.extend(chunk(b"ANMF", &anmf));
}
chunk(b"RIFF", &body)
}
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 128];
fn pixel_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 4] {
pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap().data[0]
}
#[test]
fn an_animated_webp_is_a_movie_with_its_frames() {
let bytes = webp(4, 2, &[(RED, 50), (GREEN, 100), (BLUE, 50)]);
let f = probe_bytes("a.webp", bytes.clone().into()).unwrap();
assert_eq!(f.kind, FootageKind::Video);
assert!(f.has_video && !f.has_audio);
assert_eq!((f.width, f.height), (4, 2));
assert_eq!(f.alpha, AlphaMode::Straight);
// Delays of 50, 100 and 50 ms: 20 frames a second, 0.2 s in all.
assert_eq!(f.frame_rate, FrameRate::new(20, 1));
assert_eq!(f.duration, Tick::from_units(200, 1000));
let pool = MediaPool::new();
pool.add_bytes("a.webp", bytes.into());
let first = pixel_at(&pool, &f, 0.01);
assert_eq!(&first[..3], &[1.0, 0.0, 0.0]);
let second = pixel_at(&pool, &f, 0.06);
assert_ne!(first, second, "a later frame differs from the first");
assert_eq!(&second[..3], &[0.0, 1.0, 0.0]);
assert_eq!(&pixel_at(&pool, &f, 0.14)[..3], &[0.0, 1.0, 0.0]);
// The last frame keeps its alpha, and holds past the end.
let last = pixel_at(&pool, &f, 0.17);
assert!((last[3] - 128.0 / 255.0).abs() < 1e-3, "{last:?}");
assert_eq!(pixel_at(&pool, &f, 5.0), last);
}
#[test]
fn delays_rounded_to_milliseconds_read_as_the_standard_rate() {
// 24 frames a second rounded to whole milliseconds, as ffmpeg writes them.
let delays: Vec<u32> = (0..24).map(|k| (((k + 1) * 1000 + 12) / 24 - (k * 1000 + 12) / 24) as u32).collect();
assert!(delays.iter().all(|&d| d == 41 || d == 42));
let t = timing(&delays);
assert_eq!(t.rate, FrameRate::new(24, 1));
assert_eq!(t.duration, Tick::from_seconds_f64(1.0));
assert_eq!(t.starts[5], FrameRate::new(24, 1).tick_of(5));
// 29.97: 33 and 34 ms over a long run.
let ntsc = FrameRate::new(30_000, 1001);
let delays: Vec<u32> = (0..300).map(|k| (((k + 1) * 1_001_000 / 30_000) - (k * 1_001_000 / 30_000)) as u32).collect();
assert_eq!(timing(&delays).rate, ntsc);
// Uneven timing is kept exactly.
assert_eq!(timing(&[40, 80, 40]).rate, FrameRate::new(25, 1));
assert_eq!(timing(&[33, 34, 50]).rate, FrameRate::new(1000, 1));
// As fast as possible is 100 ms, as in browsers.
assert_eq!(timing(&[0, 10]).duration, Tick::from_units(200, 1000));
}
#[test]
fn a_still_webp_stays_a_still() {
let mut still = Vec::new();
WebPEncoder::new_lossless(&mut still).write_image(&RED.repeat(4), 2, 2, ExtendedColorType::Rgba8).unwrap();
let f = probe_bytes("s.webp", still.into()).unwrap();
assert_eq!(f.kind, FootageKind::Still);
// So does an animation of one frame.
let f = probe_bytes("one.webp", webp(2, 2, &[(RED, 100)]).into()).unwrap();
assert_eq!(f.kind, FootageKind::Still);
}
#[test]
fn a_truncated_animated_webp_is_an_error_not_a_panic() {
let full = webp(4, 2, &[(RED, 50), (GREEN, 50), (BLUE, 50)]);
let f = probe_bytes("a.webp", full.clone().into()).unwrap();
for len in [0, 11, 20, 40, full.len() / 2, full.len() - 3] {
let bytes = &full[..len];
let _ = probe_bytes("t.webp", bytes.to_vec().into());
let pool = MediaPool::new();
pool.add_bytes("a.webp", bytes.to_vec().into());
// Cut before the second frame's data: no frames at all, or the ones before the cut.
let r = pool.frame_at(&f, Tick::ZERO);
if len < full.len() / 2 {
assert!(r.is_err(), "{len} bytes");
}
}
}
#[test]
fn a_huge_animation_is_refused_before_decoding() {
// 16384×16384 canvas, 10 frames: 10 GiB of RGBA. The frame data is a tiny 1×1 picture;
// the size check comes first.
let mut bytes = webp(1, 1, &[(RED, 50); 10]);
bytes[24..27].copy_from_slice(&u24(16383));
bytes[27..30].copy_from_slice(&u24(16383));
let e = super::load(&bytes).err().unwrap();
assert!(e.to_string().contains("too large"), "{e}");
}
}

View File

@ -0,0 +1,226 @@
//! Multichannel audio downmix (#903) against ffmpeg as an external oracle. Fixtures: ffmpeg with
//! one sine per channel, written to `target/fixtures/media`; tests skip when ffmpeg is missing.
use std::f64::consts::PI;
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_media::{MediaPool, probe};
use effectcraft_time::Tick;
const RATE: u32 = 48_000;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
}
/// A fixture with `layout`'s channels `names` (join order) each carrying its own sine, encoded
/// with `codec_args`. `None` when ffmpeg is missing or cannot make it.
fn fixture(name: &str, layout: &str, names: &[&str], freqs: &[u32], codec_args: &[&str]) -> Option<PathBuf> {
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
eprintln!("ffmpeg not found: skipping");
return None;
}
let out = fixtures().join(name);
if out.exists() {
return Some(out);
}
std::fs::create_dir_all(fixtures()).ok()?;
let ext = out.extension()?.to_string_lossy().to_string();
let tmp = out.with_file_name(format!("tmp-{}-{name}", std::process::id())).with_extension(ext);
let mut cmd = Command::new("ffmpeg");
cmd.args(["-hide_banner", "-loglevel", "error", "-y"]);
let mut map = String::new();
for (i, (n, f)) in names.iter().zip(freqs).enumerate() {
cmd.args(["-f", "lavfi", "-i", &format!("sine=frequency={f}:sample_rate={RATE}:duration=2")]);
map.push_str(&format!("[{i}]"));
let _ = n;
}
let post = if name.contains("loud") { ",volume=8:precision=float,alimiter=limit=1:level=disabled" } else { "" };
let joins: Vec<String> = names.iter().enumerate().map(|(i, n)| format!("{i}.0-{n}")).collect();
cmd.args(["-filter_complex", &format!("{map}join=inputs={}:channel_layout={layout}:map={}{}[a]", names.len(), joins.join("|"), post), "-map", "[a]"]);
cmd.args(codec_args).arg(&tmp);
let ok = cmd.status().ok()?.success();
if !ok {
eprintln!("ffmpeg could not make {name}");
return None;
}
std::fs::rename(&tmp, &out).ok()?;
Some(out)
}
/// Amplitude (peak) of the sine at `freq` in one channel of interleaved stereo.
fn amp(s: &[f32], ch: usize, freq: u32) -> f64 {
let (mut re, mut im, mut n) = (0.0, 0.0, 0usize);
for (i, f) in s.as_chunks::<2>().0.iter().enumerate() {
let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE);
re += f64::from(f[ch]) * w.cos();
im += f64::from(f[ch]) * w.sin();
n += 1;
}
2.0 * (re * re + im * im).sqrt() / n.max(1) as f64
}
const K: f64 = std::f64::consts::FRAC_1_SQRT_2;
struct Case {
file: &'static str,
layout: &'static str,
names: &'static [&'static str],
codec: &'static [&'static str],
/// Expected `[left, right]` gain of each channel (the same order as `names`).
gains: Vec<[f64; 2]>,
/// Allowed deviation of a channel's amplitude (lossy codecs).
tol: f64,
}
const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700];
fn five_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
let k = 1.0 / (1.0 + 2.0 * K);
(&["FL", "FR", "FC", "LFE", "BL", "BR"], vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K]])
}
fn seven_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
let k = 1.0 / (1.0 + 3.0 * K);
(
&["FL", "FR", "FC", "LFE", "BL", "BR", "SL", "SR"],
vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K], [k * K, 0.0], [0.0, k * K]],
)
}
fn cases() -> Vec<Case> {
let (n51, g51) = five_one();
let (n71, g71) = seven_one();
let k5 = 1.0 / (1.0 + 2.0 * K);
let kq = 1.0 / (1.0 + K);
vec![
Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 },
Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 },
Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 },
Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 },
Case { file: "dm71.wav", layout: "7.1", names: n71, codec: &["-c:a", "pcm_s16le"], gains: g71.clone(), tol: 0.02 },
Case { file: "dm71.flac", layout: "7.1", names: n71, codec: &["-c:a", "flac"], gains: g71, tol: 0.02 },
Case {
file: "dm50.wav",
layout: "5.0",
names: &["FL", "FR", "FC", "BL", "BR"],
codec: &["-c:a", "pcm_s16le"],
gains: vec![[k5, 0.0], [0.0, k5], [k5 * K, k5 * K], [k5 * K, 0.0], [0.0, k5 * K]],
tol: 0.02,
},
Case {
file: "dmquad.wav",
layout: "quad",
names: &["FL", "FR", "BL", "BR"],
codec: &["-c:a", "pcm_s16le"],
gains: vec![[kq, 0.0], [0.0, kq], [kq * K, 0.0], [0.0, kq * K]],
tol: 0.02,
},
]
}
/// Our stereo mix of `p` over one second, from 0.2 s in.
fn ours(p: &Path) -> Vec<f32> {
let f = probe(p).expect("probe");
MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.2), RATE as usize, RATE)
}
/// ffmpeg's own `-ac 2` downmix of the same second, interleaved. Written as 16-bit integers:
/// swresample only normalises the matrix (to keep full scale from clipping) for integer output.
fn ffmpeg_ac2(p: &Path) -> Vec<f32> {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-ss", "0.2", "-t", "1", "-i"])
.arg(p)
.args(["-ac", "2", "-ar", "48000", "-f", "s16le", "-"])
.output()
.expect("run ffmpeg");
out.stdout.as_chunks::<2>().0.iter().map(|b| f32::from(i16::from_le_bytes(*b)) / 32768.0).collect()
}
fn rms(s: &[f32]) -> f64 {
(s.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>() / s.len().max(1) as f64).sqrt()
}
fn peak(s: &[f32]) -> f64 {
s.iter().fold(0.0f64, |m, v| m.max(f64::from(v.abs())))
}
/// Each channel carries its own sine at amplitude 0.5·√... (ffmpeg's `sine` is −24 dBFS RMS,
/// peak ≈ 0.125). Every channel must reach each side at the BS.775 gain, and the overall level
/// must match ffmpeg's `-ac 2`.
#[test]
fn each_channel_reaches_stereo_at_its_bs775_gain() {
for c in cases() {
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue };
let s = ours(&p);
let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3)
for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() {
for (side, gain) in g.iter().enumerate() {
let got = amp(&s, side, freqs[i]);
let want = sine * gain;
assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]);
}
}
let theirs = ffmpeg_ac2(&p);
let (a, b) = (rms(&s), rms(&theirs));
let db = 20.0 * (a / b).log10();
assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file);
let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10();
assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file);
}
}
/// Full-scale in every channel must not clip.
#[test]
fn loud_surround_does_not_clip() {
let Some(p) = fixture("dm51_loud.wav", "5.1", &["FL", "FR", "FC", "LFE", "BL", "BR"], &[300; 6], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!(peak(&s) <= 1.001, "peak {}", peak(&s));
assert!(peak(&s) > 0.5, "audible, peak {}", peak(&s));
}
/// Mono and stereo footage keep their level (mono on both sides).
#[test]
fn mono_and_stereo_keep_their_level() {
let Some(p) = fixture("dm10.wav", "mono", &["FC"], &[440], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && (amp(&s, 1, 440) - 0.125).abs() < 0.003);
let Some(p) = fixture("dm20.wav", "stereo", &["FL", "FR"], &[440, 660], &["-c:a", "pcm_s16le"]) else { return };
let s = ours(&p);
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && amp(&s, 1, 440) < 0.003);
assert!((amp(&s, 1, 660) - 0.125).abs() < 0.003 && amp(&s, 0, 660) < 0.003);
}
/// Odd channel counts (3, 4, 7, 9, 12, 16) decode to finite stereo without panicking.
#[test]
fn hostile_channel_counts_do_not_panic() {
for (n, layout) in [(3, "3.0"), (4, "quad"), (7, "6.1"), (9, "9 channels"), (12, "12 channels"), (16, "16 channels")] {
let names: Vec<String> = (0..n).map(|i| format!("{i}")).collect();
// channel names for unnamed layouts are DEFAULT-ordered by position
let _ = names;
let file = format!("dmn{n}.wav");
let args = ["-f", "lavfi", "-i", &format!("sine=frequency=440:sample_rate={RATE}:duration=1")];
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
return;
}
let out = fixtures().join(&file);
std::fs::create_dir_all(fixtures()).expect("mkdir");
if !out.exists() {
let tmp = fixtures().join(format!("tmp-{}-{file}", std::process::id()));
let af = format!("pan={layout}|{}", (0..n).map(|i| format!("c{i}=c0")).collect::<Vec<_>>().join("|"));
let ok =
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).args(["-af", &af, "-c:a", "pcm_s16le"]).arg(&tmp).status();
if !ok.is_ok_and(|s| s.success()) {
eprintln!("ffmpeg cannot make {n} channels: skipping");
continue;
}
std::fs::rename(&tmp, &out).expect("rename");
}
let Ok(f) = probe(&out) else { continue };
let s = MediaPool::new().audio_samples(&f, Tick::ZERO, 4800, RATE);
assert_eq!(s.len(), 9600, "{n} channels");
assert!(s.iter().all(|v| v.is_finite() && v.abs() <= 1.001), "{n} channels");
}
}

View File

@ -0,0 +1,84 @@
//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the
//! footage has the size the picture is seen at, and its frames are the picture the right way up.
use std::sync::Arc;
use effectcraft_media::{MediaPool, probe_bytes};
use effectcraft_project::FootageKind;
use effectcraft_time::Tick;
const RED: [f32; 3] = [1.0, 0.0, 0.0];
const GREEN: [f32; 3] = [0.0, 1.0, 0.0];
const BLUE: [f32; 3] = [0.0, 0.0, 1.0];
const WHITE: [f32; 3] = [1.0, 1.0, 1.0];
/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag`
/// (`None`: no Exif at all).
fn quartered_jpeg(tag: Option<u8>) -> Vec<u8> {
let img = image::RgbImage::from_fn(32, 16, |x, y| {
let c = match (x < 16, y < 8) {
(true, true) => RED,
(false, true) => GREEN,
(true, false) => BLUE,
(false, false) => WHITE,
};
image::Rgb(c.map(|v| (v * 255.0) as u8))
});
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode");
let Some(tag) = tag else { return jpeg };
// APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT).
let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22];
app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08");
app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]);
app1.extend_from_slice(&[0x00; 4]);
// (Straight after the SOI marker.)
jpeg.splice(2..2, app1);
jpeg
}
/// The footage size and the colours of its four corners (top-left, top-right, bottom-left,
/// bottom-right), as imported.
fn imported(tag: Option<u8>) -> ((u32, u32), [[f32; 3]; 4]) {
let name = format!("oriented-{tag:?}.jpg");
let bytes: Arc<[u8]> = quartered_jpeg(tag).into();
let f = probe_bytes(&name, bytes.clone()).expect("probe");
assert_eq!(f.kind, FootageKind::Still);
let pool = MediaPool::new();
pool.add_bytes(&name, bytes);
let img = pool.frame_at(&f, Tick::ZERO).expect("frame");
assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size");
let (w, h) = (img.width as usize, img.height as usize);
let at = |x: usize, y: usize| {
let p = img.data[y * w + x];
[p[0], p[1], p[2]]
};
((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)])
}
#[test]
fn exif_orientation_is_applied_to_stills() {
let (landscape, portrait) = ((32, 16), (16, 32));
let cases = [
(None, landscape, [RED, GREEN, BLUE, WHITE]),
(Some(1), landscape, [RED, GREEN, BLUE, WHITE]),
(Some(2), landscape, [GREEN, RED, WHITE, BLUE]),
(Some(3), landscape, [WHITE, BLUE, GREEN, RED]),
(Some(4), landscape, [BLUE, WHITE, RED, GREEN]),
(Some(5), portrait, [RED, BLUE, GREEN, WHITE]),
(Some(6), portrait, [BLUE, RED, WHITE, GREEN]),
(Some(7), portrait, [WHITE, GREEN, BLUE, RED]),
(Some(8), portrait, [GREEN, WHITE, RED, BLUE]),
// (Not an orientation: the picture as stored.)
(Some(0), landscape, [RED, GREEN, BLUE, WHITE]),
(Some(9), landscape, [RED, GREEN, BLUE, WHITE]),
];
for (tag, size, corners) in cases {
let (got_size, got) = imported(tag);
assert_eq!(got_size, size, "orientation {tag:?}: size");
for (i, (g, want)) in got.iter().zip(corners).enumerate() {
let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2);
assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}");
}
}
}

View File

@ -9,7 +9,7 @@ use std::process::Command;
use std::sync::Arc;
use std::time::Instant;
use effectcraft_media::{MediaPool, probe};
use effectcraft_media::{MediaPool, probe, probe_bytes};
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
use effectcraft_raster::Image;
use effectcraft_render::FootageSource;
@ -336,6 +336,59 @@ fn resampled_audio_read_in_frame_pieces_matches_ffmpeg() {
}
}
/// The H.264 fixture with the rotation part of its video track header's matrix (`[a b; c d]`,
/// 16.16 fixed point) replaced: what a phone writes for a movie shot with the camera turned.
fn turned_mp4(p: &Path, abcd: [i32; 4]) -> Arc<[u8]> {
let mut bytes = std::fs::read(p).expect("read");
// (The video track comes first; a version 0 `tkhd` has its matrix 44 bytes after the type.)
let at = bytes.windows(4).position(|w| w == b"tkhd").expect("tkhd");
assert_eq!(bytes[at + 4], 0, "a version 0 tkhd");
for (slot, v) in [0, 1, 3, 4].into_iter().zip(abcd) {
let o = at + 44 + slot * 4;
bytes[o..o + 4].copy_from_slice(&v.to_be_bytes());
}
bytes.into()
}
/// A movie whose track header says it is shown rotated (a phone held upright) imports the way it
/// is shown: the footage has the turned size and its frames are turned, at full and reduced size.
#[test]
fn rotated_mp4_imports_upright() {
let Some(p) = h264_mp4() else { return };
let plain_footage = probe(&p).expect("probe");
let pool = MediaPool::new();
let t = t_of(plain_footage.frame_rate, 3);
let plain = pool.frame(ItemId(1), &plain_footage, t).expect("frame");
let (w, h) = (plain.width as usize, plain.height as usize);
let one = 0x1_0000;
// The matrix, the turned size and the plain frame's pixel shown at (x, y).
type From = fn(usize, usize, usize, usize) -> (usize, usize);
let cases: [(&str, [i32; 4], (usize, usize), From); 3] = [
("90", [0, one, -one, 0], (h, w), |x, y, _, h| (y, h - 1 - x)),
("180", [-one, 0, 0, -one], (w, h), |x, y, w, h| (w - 1 - x, h - 1 - y)),
("270", [0, -one, one, 0], (h, w), |x, y, w, _| (w - 1 - y, x)),
];
for (name, matrix, (tw, th), from) in cases {
let path = format!("turned-{name}.mp4");
let bytes = turned_mp4(&p, matrix);
let f = probe_bytes(&path, bytes.clone()).expect("probe");
assert_eq!((f.width as usize, f.height as usize), (tw, th), "{name}°: size");
pool.add_bytes(&path, bytes);
let img = pool.frame(ItemId(2), &f, t).expect("frame");
assert_eq!((img.width as usize, img.height as usize), (tw, th), "{name}°: frame size");
for (x, y) in [(0, 0), (tw - 1, 0), (0, th - 1), (tw - 1, th - 1), (tw / 3, th / 5), (tw / 2, th - 7)] {
let (sx, sy) = from(x, y, w, h);
assert_eq!(img.data[y * tw + x], plain.data[sy * w + sx], "{name}°: pixel ({x}, {y})");
}
// (A reduced-resolution render asks for the turned size.)
let (hw, hh) = (f.width / 2, f.height / 2);
let sized = pool.frame_at_size(ItemId(2), &f, t, hw, hh).expect("frame");
assert_eq!((sized.width, sized.height), (hw, hh), "{name}°: reduced size");
let db = psnr(&sized, &effectcraft_raster::resample(&img, hw, hh));
assert!(db > 45.0, "{name}° at half size: {db:.2} dB");
}
}
#[test]
fn prores_4444_with_alpha() {
let Some(p) = prores_mov() else { return };
@ -778,9 +831,14 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() {
}
at += 1024;
}
// ffmpeg's sine source has an amplitude of 1/8, on both channels (mono is duplicated).
// ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries
// channels 0 and 1 at full level and the others at half, so a multichannel file sounds as
// its stereo downmix (tests/downmix.rs checks that matrix channel by channel).
let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false);
let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::<f64>();
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
let rms = (sum / n.max(1) as f64).sqrt();
assert!((rms - 0.125 / 2f64.sqrt()).abs() < 0.003, "{}: rms {rms}", p.display());
assert!((rms - want).abs() < 0.003, "{}: rms {rms}, expected {want}", p.display());
eprintln!("{}: read {secs} s in {:?}, slowest block after the first {worst:?}", p.display(), t0.elapsed());
assert_eq!(pool.stats().opened, 1, "opened once, not per block");
}
@ -890,7 +948,9 @@ fn dpx_stills_match_ffmpeg() {
assert!(effectcraft_media::is_importable(&p));
let f = probe(&p).expect(pf);
assert_eq!(f.kind, FootageKind::Still);
assert_eq!((f.width, f.height, f.codec.as_str()), (250, 100, codec), "{pf}");
assert_eq!((f.width, f.height), (250, 100), "{pf}");
// Some ffmpeg builds state the transfer (", linear"), which the codec name then carries.
assert!(f.codec == codec || f.codec.starts_with(&format!("{codec}, ")), "{pf}: {}", f.codec);
assert_eq!(f.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, "{pf}");
let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect(pf);
let want = ffmpeg_rgba(&p, !codec.contains("8-bit"));
@ -967,3 +1027,54 @@ fn cineon_still_shows_code_values() {
assert!(effectcraft_media::probe_bytes("cut.cin", cut).is_err(), "{n}");
}
}
/// #329: a frame read that is called off (a scrub has moved on) gives up before the decoder
/// seeks and decodes forward from the keyframe, so it no longer holds the decoder the newest
/// frame waits for; the frame decodes as usual when it is wanted again.
#[test]
fn a_called_off_read_does_not_decode() {
use std::sync::atomic::AtomicBool;
let Some(p) = h264_mp4() else { return };
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
let t = t_of(f.frame_rate, 45);
let off = Arc::new(AtomicBool::new(true));
let mut got = None;
pool.cancellable(&off, &mut || got = Some(pool.frame(ItemId(1), &f, t)));
assert!(got.expect("ran").is_none(), "a called-off read decoded its frame");
assert_eq!(pool.stats().frames, 0, "nothing cached for it");
let on = Arc::new(AtomicBool::new(false));
let mut got = None;
pool.cancellable(&on, &mut || got = pool.frame(ItemId(1), &f, t));
let img = got.expect("decoded once wanted again");
assert_eq!((img.width, img.height), (1920, 1080));
}
/// H.264 High 4:2:2 10-bit: the software decoder lacks it; the pool says why, once, and the
/// footage reports it (not a black frame without a word).
#[test]
fn an_unsupported_stream_reports_why_it_shows_no_picture() {
let Some(p) = ffmpeg_fixture(
"h264_high422_10bit.mp4",
&["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"],
) else {
return;
};
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
assert_eq!(pool.problem(&f), None, "nothing is wrong before a frame was asked for");
if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() {
// a decoder that handles 4:2:2 (FilmCraft's, or a hardware one): no notice
assert_eq!(pool.problem(&f), None);
return;
}
let why = pool.problem(&f).expect("a failed frame leaves a reason");
assert!(why.contains("4:2:2"), "names the stream: {why}");
assert!(why.contains("isn't supported"), "says it is unsupported: {why}");
assert!(!why.contains("decode error"), "no error wrappers: {why}");
// it stays for the other frames, and is dropped when the file is forgotten
assert!(pool.frame(ItemId(1), &f, Tick::from_seconds_f64(0.5)).is_none());
assert_eq!(pool.problem(&f).as_deref(), Some(why.as_str()));
pool.forget(&f.path);
assert_eq!(pool.problem(&f), None);
}

View File

@ -0,0 +1,101 @@
//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and
//! some audio must not grow peak memory by anything near the file's size.
//!
//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so
//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place,
//! the box is skipped by its header. The only test in this binary, so peak RSS is its own.
//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`).
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_media::{MediaPool, probe};
use effectcraft_time::Tick;
const PADDING: u64 = 256 << 20;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
}
/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test
/// processes never see a partial file). `None` without ffmpeg.
fn small_mp4() -> Option<PathBuf> {
let out = fixtures().join("in_place_small.mp4");
if out.exists() {
return Some(out);
}
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
return None;
}
std::fs::create_dir_all(fixtures()).unwrap();
let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id()));
let st = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"])
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
.arg(&tmp)
.status()
.unwrap();
assert!(st.success(), "ffmpeg failed");
std::fs::rename(&tmp, &out).unwrap();
Some(out)
}
/// `/proc/self/status` field `key` in KiB.
fn status_kib(key: &str) -> Option<u64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let line = s.lines().find(|l| l.starts_with(key))?;
line.split_whitespace().nth(1)?.parse().ok()
}
/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that.
fn reset_peak() -> Option<u64> {
let _ = std::fs::write("/proc/self/clear_refs", "5");
status_kib("VmHWM:").map(|k| k * 1024)
}
#[test]
fn a_long_movie_is_read_in_place_not_whole() {
if !cfg!(target_os = "linux") {
eprintln!("peak RSS is read from /proc: skipping");
return;
}
let Some(small) = small_mp4() else {
eprintln!("ffmpeg not found: skipping");
return;
};
// The same movie with 256 MiB of padding after its boxes.
let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id()));
std::fs::copy(&small, &large).unwrap();
let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap();
f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap();
f.write_all(b"free").unwrap();
f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap();
drop(f);
// Warm up on the small file (code paths, allocator, decoder tables).
let small_footage = probe(&small).unwrap();
let pool = MediaPool::new();
pool.set_read_ahead(false);
let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap();
let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000);
let before = reset_peak().unwrap();
let footage = probe(&large).unwrap();
let frame = pool.frame_at(&footage, Tick::ZERO).unwrap();
let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000);
let after = status_kib("VmHWM:").unwrap() * 1024;
drop(pool);
let _ = std::fs::remove_file(&large);
let growth = after.saturating_sub(before);
eprintln!("peak RSS growth: {} KiB", growth >> 10);
assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20);
// Same movie, same results.
assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage });
assert_eq!(frame, small_frame);
assert_eq!(audio, small_audio);
assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes");
}

View File

@ -8,6 +8,7 @@
pub mod build;
pub mod essential;
pub mod limits;
pub mod props;
pub mod render_queue;
pub mod render_templates;
@ -48,6 +49,42 @@ pub fn saved_by(text: &str) -> Option<String> {
serde_json::from_str::<Meta>(text).ok()?.saved_by
}
/// The compositions that were open when a project file was saved (`activeComp` and `openComps`
/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project
/// content: opening a comp doesn't make the project unsaved, and it isn't part of
/// [`Project`]. Older files have none; older versions ignore it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedView {
/// The comp shown in the viewer and the Timeline.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_comp: Option<ItemId>,
/// The open comps (Timeline tabs), in tab order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub open_comps: Vec<ItemId>,
}
/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that
/// aren't ids are ignored (whether an id still names a comp is up to the caller).
pub fn saved_view(text: &str) -> SavedView {
#[derive(Deserialize)]
struct Meta {
#[serde(rename = "activeComp", default)]
active_comp: Option<serde_json::Value>,
#[serde(rename = "openComps", default)]
open_comps: Option<serde_json::Value>,
}
let Ok(m) = serde_json::from_str::<Meta>(text) else { return SavedView::default() };
SavedView {
active_comp: m.active_comp.as_ref().and_then(serde_json::Value::as_u64).map(ItemId),
open_comps: m
.open_comps
.as_ref()
.and_then(serde_json::Value::as_array)
.map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()),
}
}
/// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`.
pub fn is_newer_version(a: &str, b: &str) -> bool {
let parts = |v: &str| -> [u64; 3] {
@ -301,6 +338,71 @@ pub enum Renderer {
Advanced3D,
}
/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸
/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Resolution {
#[default]
Auto,
Full,
Half,
Third,
Quarter,
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
Custom(u8),
}
impl Resolution {
pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter];
pub fn label(self) -> &'static str {
match self {
Resolution::Auto => "Auto",
Resolution::Full => "Full",
Resolution::Half => "Half",
Resolution::Third => "Third",
Resolution::Quarter => "Quarter",
Resolution::Custom(_) => "Custom",
}
}
pub fn is_auto(&self) -> bool {
*self == Resolution::Auto
}
/// The down-sample factor (render every n-th pixel); `None` for Auto.
pub fn factor(self) -> Option<u8> {
match self {
Resolution::Auto => None,
Resolution::Full => Some(1),
Resolution::Half => Some(2),
Resolution::Third => Some(3),
Resolution::Quarter => Some(4),
Resolution::Custom(n) => Some(n),
}
}
/// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40).
pub fn from_factor(n: u64) -> Resolution {
match n.clamp(1, 40) {
1 => Resolution::Full,
2 => Resolution::Half,
3 => Resolution::Third,
4 => Resolution::Quarter,
n => Resolution::Custom(n as u8),
}
}
/// Render scale for a viewer magnification (and display pixel density).
pub fn scale(self, zoom: f32, ppp: f32) -> f64 {
match self.factor() {
Some(n) => 1.0 / f64::from(n.clamp(1, 40)),
// As in After Effects, Auto renders the pixels the magnification needs: the coarsest
// whole factor that still gives every screen pixel a rendered one (Full above 50 %,
// Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417).
None => {
let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor();
if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 }
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comp {
pub width: u32,
@ -354,6 +456,11 @@ pub struct Comp {
/// Essential Graphics: the controls this comp exposes to instances and templates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub essential: Option<essential::EssentialGraphics>,
/// The Composition panel's Resolution, saved with the project as in After Effects. It changes
/// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of
/// the undo history.
#[serde(default, skip_serializing_if = "Resolution::is_auto")]
pub resolution: Resolution,
}
/// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position.
@ -399,6 +506,7 @@ impl Comp {
guides: vec![],
global_light: styles::GlobalLight::default(),
essential: None,
resolution: Resolution::Auto,
}
}
/// Whether the composition has a frame at its time `t`: nested, it shows nothing before it
@ -698,8 +806,8 @@ impl Layer {
}
impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and
/// Hide Shy Layers.
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
/// Shy Layers and the viewer's Resolution.
pub fn same_pixels(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here); the layers last.
let Comp {
@ -728,6 +836,7 @@ impl Comp {
global_light,
guides,
essential,
resolution: _,
} = self;
*width == o.width
&& *height == o.height
@ -756,14 +865,15 @@ impl Comp {
&& layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// The comp with those switches at their defaults (borrowed when they already are), for
/// keys that hash the whole comp.
/// The comp with those switches and the Resolution at their defaults (borrowed when they
/// already are), for keys that hash the whole comp.
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
return std::borrow::Cow::Borrowed(self);
}
let mut c = self.clone();
c.hide_shy = false;
c.resolution = Resolution::Auto;
for l in &mut c.layers {
l.switches = l.switches.pixels();
}
@ -1246,7 +1356,9 @@ impl Project {
_ => None,
}
}
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, kind: ItemKind) -> ItemId {
/// Adds an item; its size, frame rate and the like are brought within [`limits`] first.
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, mut kind: ItemKind) -> ItemId {
limits::repair_kind(name, &mut kind, &mut vec![]);
let id = ItemId(self.alloc());
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None });
id
@ -1315,14 +1427,20 @@ impl Project {
/// to read back, so a save never writes a file that can't be opened (JSON has no NaN or
/// infinity: such a value would be written as `null`).
pub fn to_file_json(&self) -> Result<String, ProjectError> {
self.to_file_json_with_view(&SavedView::default())
}
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).
pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {
#[derive(Serialize)]
struct File<'a> {
#[serde(rename = "savedBy")]
saved_by: &'a str,
#[serde(flatten)]
view: &'a SavedView,
#[serde(flatten)]
project: &'a Project,
}
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self })
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self })
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
Project::from_json(&text)
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
@ -1336,12 +1454,22 @@ impl Project {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Result<Project, ProjectError> {
Project::from_json_repaired(s).map(|(p, _)| p)
}
/// [`Project::from_json`], also returning one line per value that was out of [`limits`] and
/// was corrected (a composition of a billion pixels, a zero frame rate…), so a corrupt or
/// hostile file still opens, within bounds, and the user can be told what changed.
pub fn from_json_repaired(s: &str) -> Result<(Project, Vec<String>), ProjectError> {
let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?;
if p.schema > SCHEMA_VERSION {
return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema)));
}
p.fix_next_id();
Ok(p)
let mut notes = vec![];
for item in p.items.values_mut() {
limits::repair_item(item, &mut notes);
}
Ok((p, notes))
}
}

View File

@ -0,0 +1,227 @@
//! The bounds every project holds to (#788). After Effects limits compositions, solids and
//! imported images to 30,000 × 30,000 pixels; the Composition Settings commands already used that
//! limit, a frame-rate range and a motion-blur sample range. A project file is input like any
//! other: [`Project::from_json_repaired`](crate::Project::from_json_repaired) brings what it reads
//! into these bounds (and says what it changed) and [`Project::add_item`](crate::Project::add_item)
//! does the same for every new item, so a corrupt or hostile file can't make the renderer allocate
//! a frame of billions of pixels or step through zero-length frames.
use std::fmt::Display;
use std::sync::Arc;
use effectcraft_time::FrameRate;
use crate::{Comp, Footage, Item, ItemKind, Solid};
/// The largest width or height of a composition, solid or footage frame, in pixels.
pub const MAX_SIZE: u32 = 30_000;
/// The slowest frame rate, in frames per second.
pub const MIN_FPS: f64 = 0.001;
/// The fastest frame rate, in frames per second.
pub const MAX_FPS: f64 = 1000.0;
/// The narrowest pixel aspect ratio.
pub const MIN_PIXEL_ASPECT: f64 = 0.1;
/// The widest pixel aspect ratio.
pub const MAX_PIXEL_ASPECT: f64 = 10.0;
/// Composition Settings ▸ Advanced ▸ Samples Per Frame.
pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64);
/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit.
pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256);
/// A width or height from 1 to [`MAX_SIZE`].
pub fn size(v: u64) -> u32 {
// In range after the clamp, so the cast can't truncate.
v.clamp(1, u64::from(MAX_SIZE)) as u32
}
/// [`size`] of a measured length (rounded; 1 for NaN).
pub fn size_f64(v: f64) -> u32 {
if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIZE)) as u32 } else { 1 }
}
/// A pixel aspect ratio from [`MIN_PIXEL_ASPECT`] to [`MAX_PIXEL_ASPECT`] (square for a value
/// that isn't a positive number).
pub fn pixel_aspect(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.clamp(MIN_PIXEL_ASPECT, MAX_PIXEL_ASPECT) } else { 1.0 }
}
/// A frame rate from [`MIN_FPS`] to [`MAX_FPS`]; `fallback` for a zero or negative rate.
pub fn frame_rate(r: FrameRate, fallback: FrameRate) -> FrameRate {
if r.num <= 0 || r.den <= 0 {
return fallback;
}
let fps = r.as_f64();
if (MIN_FPS..=MAX_FPS).contains(&fps) { r } else { FrameRate::from_f64(fps.clamp(MIN_FPS, MAX_FPS)) }
}
/// Records one corrected value.
fn note(notes: &mut Vec<String>, item: &str, what: &str, from: impl Display, to: impl Display) {
notes.push(format!("{item}: {what} {from} → {to}"));
}
fn fps(r: FrameRate) -> String {
format!("{}/{} fps", r.num, r.den)
}
/// Brings a read item (and its proxy) within the limits; one line per corrected value is added
/// to `notes`.
pub(crate) fn repair_item(item: &mut Item, notes: &mut Vec<String>) {
repair_kind(&item.name, &mut item.kind, notes);
if let Some(proxy) = &mut item.proxy {
repair_footage(&format!("{} (proxy)", item.name), &mut proxy.footage, notes);
}
}
/// Brings `kind` within the limits; one line per corrected value is added to `notes`.
pub(crate) fn repair_kind(name: &str, kind: &mut ItemKind, notes: &mut Vec<String>) {
match kind {
ItemKind::Comp(c) => {
if let Some(fixed) = repair_comp(name, c, notes) {
*c = Arc::new(fixed);
}
}
ItemKind::Solid(s) => repair_solid(name, s, notes),
ItemKind::Footage(f) => repair_footage(name, f, notes),
ItemKind::Folder => {}
}
}
/// The comp with its settings within the limits, or `None` when they already are (so a
/// composition shared with undo snapshots is only copied when something changes).
fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
let width = c.width.clamp(1, MAX_SIZE);
let height = c.height.clamp(1, MAX_SIZE);
let pa = pixel_aspect(c.pixel_aspect);
let rate = frame_rate(c.frame_rate, FrameRate::FPS_29_97);
// At least one frame long.
let duration = c.duration.max(rate.frame_duration());
let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1);
let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1);
let unchanged = (width, height, rate, duration, samples, adaptive)
== (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit)
&& pa.to_bits() == c.pixel_aspect.to_bits();
if unchanged {
return None;
}
let mut n = c.clone();
if (width, height) != (c.width, c.height) {
note(notes, name, "size", format_args!("{} × {} px", c.width, c.height), format_args!("{width} × {height} px"));
}
if pa.to_bits() != c.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", c.pixel_aspect, pa);
}
if rate != c.frame_rate {
note(notes, name, "frame rate", fps(c.frame_rate), fps(rate));
}
if duration != c.duration {
note(notes, name, "duration", format_args!("{:.3} s", c.duration.seconds()), format_args!("{:.3} s", duration.seconds()));
n.work_area = (effectcraft_time::Tick::ZERO, duration);
}
if samples != c.motion_blur_samples {
note(notes, name, "motion blur samples per frame", c.motion_blur_samples, samples);
}
if adaptive != c.motion_blur_adaptive_limit {
note(notes, name, "adaptive sample limit", c.motion_blur_adaptive_limit, adaptive);
}
(n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration);
(n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive);
Some(n)
}
fn repair_solid(name: &str, s: &mut Solid, notes: &mut Vec<String>) {
let (w, h) = (s.width.clamp(1, MAX_SIZE), s.height.clamp(1, MAX_SIZE));
if (w, h) != (s.width, s.height) {
note(notes, name, "size", format_args!("{} × {} px", s.width, s.height), format_args!("{w} × {h} px"));
(s.width, s.height) = (w, h);
}
let pa = pixel_aspect(s.pixel_aspect);
if pa.to_bits() != s.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", s.pixel_aspect, pa);
s.pixel_aspect = pa;
}
}
fn repair_footage(name: &str, f: &mut Footage, notes: &mut Vec<String>) {
// Audio-only footage has no frame (0 × 0).
let (w, h) = (f.width.min(MAX_SIZE), f.height.min(MAX_SIZE));
if (w, h) != (f.width, f.height) {
note(notes, name, "size", format_args!("{} × {} px", f.width, f.height), format_args!("{w} × {h} px"));
(f.width, f.height) = (w, h);
}
let pa = pixel_aspect(f.pixel_aspect);
if pa.to_bits() != f.pixel_aspect.to_bits() {
note(notes, name, "pixel aspect ratio", f.pixel_aspect, pa);
f.pixel_aspect = pa;
}
let rate = frame_rate(f.frame_rate, FrameRate::FPS_30);
if rate != f.frame_rate {
note(notes, name, "frame rate", fps(f.frame_rate), fps(rate));
f.frame_rate = rate;
}
if let Some(native) = f.native_rate {
let rate = frame_rate(native, FrameRate::FPS_30);
if rate != native {
note(notes, name, "native frame rate", fps(native), fps(rate));
f.native_rate = Some(rate);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_time::Tick;
#[test]
fn helpers_clamp_and_refuse_nonsense() {
assert_eq!(size(0), 1);
assert_eq!(size(u64::from(u32::MAX) + 5), MAX_SIZE);
assert_eq!(size_f64(f64::NAN), 1);
assert_eq!(size_f64(1e12), MAX_SIZE);
assert_eq!(pixel_aspect(0.0), 1.0);
assert_eq!(pixel_aspect(-2.0), 1.0);
assert_eq!(pixel_aspect(1e9), MAX_PIXEL_ASPECT);
assert_eq!(frame_rate(FrameRate { num: 0, den: 1 }, FrameRate::FPS_25), FrameRate::FPS_25);
assert_eq!(frame_rate(FrameRate { num: 24, den: -1 }, FrameRate::FPS_25), FrameRate::FPS_25);
assert_eq!(frame_rate(FrameRate::new(1_000_000_000_000_000_000, 1), FrameRate::FPS_25).as_f64(), MAX_FPS);
assert_eq!(frame_rate(FrameRate::FPS_29_97, FrameRate::FPS_25), FrameRate::FPS_29_97);
}
#[test]
fn add_item_and_reading_a_file_hold_footage_to_the_limits() {
let mut p = crate::Project::default();
let wild = Footage { width: u32::MAX, height: 0, pixel_aspect: 0.0, frame_rate: FrameRate { num: 1, den: 0 }, ..Default::default() };
let id = p.add_item("Clip", effectcraft_color::Label::None, None, ItemKind::Footage(wild));
let ItemKind::Footage(f) = &p.items[&id].kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.pixel_aspect, f.frame_rate), (MAX_SIZE, 0, 1.0, FrameRate::FPS_30));
// The same values in a file are corrected, and each correction is reported.
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
let kind = &mut v["items"][id.0.to_string()]["kind"];
kind["width"] = serde_json::json!(4_000_000_000u64);
kind["native_rate"] = serde_json::json!({"num": -24, "den": 1});
let mut proxy = kind.clone();
proxy["frame_rate"] = serde_json::json!({"num": 0, "den": 0});
v["items"][id.0.to_string()]["proxy"] = serde_json::json!({"footage": proxy});
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
let ItemKind::Footage(b) = &back.items[&id].kind else { panic!("footage") };
assert_eq!((b.width, b.native_rate), (MAX_SIZE, Some(FrameRate::FPS_30)));
assert_eq!(
notes,
[
"Clip: size 4000000000 × 0 px → 30000 × 0 px",
"Clip: native frame rate -24/1 fps → 30/1 fps",
"Clip (proxy): size 4000000000 × 0 px → 30000 × 0 px",
"Clip (proxy): frame rate 0/0 fps → 30/1 fps",
"Clip (proxy): native frame rate -24/1 fps → 30/1 fps",
]
);
}
#[test]
fn a_comp_in_range_is_not_copied() {
let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0));
let mut notes = vec![];
assert!(repair_comp("Comp", &c, &mut notes).is_none());
assert!(notes.is_empty());
}
}

View File

@ -65,6 +65,54 @@ fn animate_and_roundtrip_json() {
let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0));
}
#[test]
fn comp_resolution_is_saved_and_old_projects_load_with_auto() {
use crate::Resolution;
let (mut p, cid) = project_with_layer();
// Auto (the default) isn't written, so older versions read the file as before.
assert!(!p.to_file_json().unwrap().contains("\"resolution\""));
p.comp_mut(cid).unwrap().resolution = Resolution::Custom(5);
let q = Project::from_json(&p.to_file_json().unwrap()).unwrap();
assert_eq!(q.comp(cid).unwrap().resolution, Resolution::Custom(5));
p.comp_mut(cid).unwrap().resolution = Resolution::Half;
let text = p.to_file_json().unwrap();
assert_eq!(Project::from_json(&text).unwrap().comp(cid).unwrap().resolution, Resolution::Half);
// A project saved before comps had a resolution opens with Auto.
fn strip(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(m) => {
m.remove("resolution");
m.values_mut().for_each(strip);
}
serde_json::Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
strip(&mut old);
assert_eq!(Project::from_json(&old.to_string()).unwrap().comp(cid).unwrap().resolution, Resolution::Auto);
// It's not part of the comp's pixels.
let (a, b) = (p.comp(cid).unwrap(), q.comp(cid).unwrap());
assert!(a != b && a.same_pixels(b));
assert_eq!(format!("{:?}", a.pixel_form()), format!("{:?}", b.pixel_form()));
}
#[test]
fn resolution_factors_and_scales() {
use crate::Resolution;
assert_eq!(Resolution::from_factor(1), Resolution::Full);
assert_eq!(Resolution::from_factor(4), Resolution::Quarter);
assert_eq!(Resolution::from_factor(0), Resolution::Full);
assert_eq!(Resolution::from_factor(7), Resolution::Custom(7));
assert_eq!(Resolution::from_factor(1000), Resolution::Custom(40));
assert_eq!(Resolution::Custom(5).factor(), Some(5));
assert_eq!(Resolution::Auto.factor(), None);
// A hostile factor from a file renders within the Custom range.
assert_eq!(Resolution::Custom(0).scale(1.0, 1.0), 1.0);
assert_eq!(Resolution::Custom(255).scale(1.0, 1.0), 1.0 / 40.0);
assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0);
}
#[test]
fn unique_names_and_cycles() {
let (p, cid) = project_with_layer();
@ -121,3 +169,16 @@ fn project_files_name_their_version_and_always_read_back() {
assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9"));
assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1"));
}
#[test]
fn project_files_remember_the_open_comps() {
let (p, cid) = project_with_layer();
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] };
let text = p.to_file_json_with_view(&view).unwrap();
assert_eq!(crate::saved_view(&text), view);
assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project");
// Files from before #748 have no view; a garbled one is ignored, not an error.
assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default());
let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1);
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] });
}

View File

@ -74,7 +74,8 @@ pub fn audible(project: &Project, l: &Layer) -> bool {
})
}
fn db_to_gain(db: f64) -> f32 {
/// Linear gain of an Audio Levels value in dB; -96 dB and below is silence.
pub fn db_to_gain(db: f64) -> f32 {
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
}

View File

@ -23,6 +23,7 @@ pub mod text;
pub mod three_d;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub use auto::{AutoKey, AutoPick};
pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore};
@ -110,6 +111,93 @@ pub trait FootageSource: Send + Sync {
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
/// Run `f`, which reads frames from this source, as work that can be called off: once
/// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding
/// forward) stop early and their frames come back `None`. Sources that can't stop a decode
/// just run `f`. See [`CancellableFootage`].
fn cancellable(&self, _cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
f()
}
/// Reads through this source have been called off: frames may have come back `None` that
/// exist, so what was made from them must not be kept.
fn called_off(&self) -> bool {
false
}
/// Why `footage` shows no picture although its file is there: a read of it failed because
/// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged
/// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it
/// decodes, and for sources that don't track failures. Stays set until the file is forgotten.
fn problem(&self, _footage: &Footage) -> Option<String> {
None
}
}
/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel`
/// (a viewer frame nobody waits for any more, #329). Every read goes through
/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread
/// (a parallel loop) answer to this render's flag, and reads for other renders never do.
pub struct CancellableFootage<'a> {
pub inner: &'a dyn FootageSource,
pub cancel: Arc<AtomicBool>,
}
impl CancellableFootage<'_> {
fn read<R>(&self, f: impl FnOnce() -> Option<R>) -> Option<R> {
if self.cancel.load(Ordering::Relaxed) {
return None;
}
let (mut f, mut out) = (Some(f), None);
self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f()));
out
}
}
impl FootageSource for CancellableFootage<'_> {
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.read(|| self.inner.frame(item, footage, t))
}
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_at_size(item, footage, t, width, height))
}
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
self.inner.audio(item, footage, t, frames, rate)
}
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
self.inner.aux(item, footage, t)
}
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
self.inner.model(item, footage)
}
fn set_cache_budget(&self, bytes: usize) {
self.inner.set_cache_budget(bytes)
}
fn purge(&self) {
self.inner.purge()
}
fn forget(&self, path: &str) {
self.inner.forget(path)
}
fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
self.inner.set_conform_folder(folder)
}
fn cache_budget(&self) -> Option<usize> {
self.inner.cache_budget()
}
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
self.inner.vector_frame(item, footage, scale)
}
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
self.inner.cancellable(cancel, f)
}
fn called_off(&self) -> bool {
self.cancel.load(Ordering::Relaxed) || self.inner.called_off()
}
fn problem(&self, footage: &Footage) -> Option<String> {
self.inner.problem(footage)
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_with(item, footage, t, size, float))
}
}
/// Footage that can be rasterised at any scale (SVG).
@ -402,6 +490,16 @@ pub trait FxTarget {
fn gpu(&mut self, steps: &[FxStep]) -> bool;
/// Run one CPU effect: `f` maps the buffer.
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf);
/// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own
/// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`].
/// Authored buffers (text, shapes) only span their painted content; this gives the stack
/// the room a sized source's buffer already has (#659).
fn cover(&mut self, size: [f64; 2]) {
self.cpu(&mut |mut buf| {
buf.cover(size);
buf
});
}
}
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
@ -604,6 +702,12 @@ impl<'a> Renderer<'a> {
}
}
/// The layer cache to keep this render's buffers in: none once its footage reads were
/// called off, as they may have come back empty ([`FootageSource::called_off`], #329).
fn cache_to_keep(&self) -> Option<&'a LayerCache> {
self.cache.filter(|_| !self.footage.called_off())
}
/// A layer's Quality switch, limited by the precomp layers above it when
/// [`RenderOpts::nested_switches`] is on.
pub fn quality(&self, layer: &Layer) -> Quality {
@ -757,10 +861,6 @@ impl<'a> Renderer<'a> {
Region::Full
}
/// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are
/// always continuously rasterised, and Continuously Rasterize shape layers and vector footage —
/// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so
/// vector content is drawn at its final size and stays sharp when scaled up).
/// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as
/// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats
/// re-running its effects every frame.
@ -774,11 +874,14 @@ impl<'a> Renderer<'a> {
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
}
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
/// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector
/// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded
/// up, so vector content is drawn at its final size and stays sharp when scaled up).
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
let s = self.opts.scale;
let continuous = match layer.source {
LayerSource::Text => true,
LayerSource::Shape => layer.switches.collapse,
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => {
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
@ -1029,6 +1132,15 @@ impl<'a> Renderer<'a> {
let Some(fx) = layer.effects() else { return };
let lt = layer.layer_time(ctx.time);
let layer_size = ctx.effect_bounds(layer).0;
// #659: an authored layer's buffer spans only its painted content while the stack works
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
// already has, so tiling or displacement can place pixels outside the content instead
// of losing them at its edge. Only when something will actually run, and not for an
// adjustment layer, whose buffer is the comp below and already spans it.
if !adjustment
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
target.cover(layer_size);
}
let mut mask_shapes = masks::shapes(ctx, layer);
if origin != [0.0; 2] {
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
@ -1292,7 +1404,7 @@ impl<'a> Renderer<'a> {
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }));
}
return Some(Arc::new(img));
@ -1340,7 +1452,7 @@ impl<'a> Renderer<'a> {
let mut nb = (*b).clone();
color::convert(&mut nb.img, &c);
let nb = Arc::new(nb);
if let (Some(cache), Some(k)) = (self.cache, key) {
if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
cache.insert(k, nb.clone());
}
nb
@ -1358,7 +1470,7 @@ impl<'a> Renderer<'a> {
return Some((b, key));
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, flat.clone());
}
Some((flat, key))
@ -1389,7 +1501,7 @@ impl<'a> Renderer<'a> {
}
let buf = self.masked_source(ctx, layer)?;
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, buf.clone());
}
Some((buf, key))
@ -1414,7 +1526,7 @@ impl<'a> Renderer<'a> {
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
}
let buf = Arc::new(buf);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, buf.clone());
}
Some(buf)
@ -1446,7 +1558,7 @@ impl<'a> Renderer<'a> {
}
let body = Arc::new(st.body);
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, body.clone());
for (i, (b, _)) in passes.iter().enumerate() {
c.insert(cache::derive(k, i as u64 + 1), b.clone());
@ -2070,7 +2182,7 @@ impl<'a> Renderer<'a> {
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let foot = Arc::new(foot);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, foot.clone());
}
Some(foot)

View File

@ -135,6 +135,32 @@ fn track_matte_alpha() {
assert!(img.get(150, 50)[3] < 0.01);
}
/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain
/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864).
#[test]
fn track_matte_kinds_keep_and_invert() {
for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]);
if inside {
assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
} else {
assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
}
}
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
@ -268,6 +294,23 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC
r.comp_frame(cid, t)
}
/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer
/// made from a frame that came back empty (an effect's layer parameter) would be reused by the
/// next render of that time.
#[test]
fn a_called_off_render_keeps_nothing_in_the_layer_cache() {
let (p, cid) = cache_scene();
let t = Tick::from_seconds_f64(0.5);
let cache = crate::LayerCache::default();
let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, t);
assert_eq!(cache.stats().entries, 0, "a called-off render kept layers");
render_cached(&p, cid, t, Some(&cache));
assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers");
}
/// [`add_effect`] for a 200×100 layer.
fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) {
add_effect(p, l, id, [200.0, 100.0], vals);
@ -597,6 +640,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
}
}
/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs
/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect
/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the
/// probe is the x-span of the painted pixels, not their count.
#[test]
fn effects_on_text_reach_past_the_text_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
p.comp_mut(cid).unwrap().layers = vec![l];
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
let (mut lo, mut hi) = (u32::MAX, 0u32);
for y in 0..100 {
for x in 0..200 {
if tiled.get(x as i64, y as i64)[3] > 0.5 {
lo = lo.min(x);
hi = hi.max(x);
}
}
}
assert!(
painted > 50 && hi.saturating_sub(lo) > 100,
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
);
}
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
#[test]
fn cc_smear_trails_text_past_its_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0);
let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
add_effect_200(
&mut p,
&mut l,
"ec.distort.ccsmear",
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l];
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
/// matte keeps the centre of a full-comp solid (#227).

View File

@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize {
(x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count()
}
#[test]
fn continuously_rasterized_shape_stays_sharp_when_scaled_up() {
let run = |cr: bool| {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent
/// null's, with the Continuously Rasterize switch `cr`.
fn scaled_square(cr: bool, via_parent: bool) -> Image {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
l.switches.collapse = cr;
let mut c = comp.clone();
if via_parent {
let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None);
set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0]));
l.parent = Some(null.id);
c.layers = vec![l, null];
} else {
set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
l.switches.collapse = cr;
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
}
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
}
/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up
/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased
/// edge, whether or not the Continuously Rasterize switch is on.
#[test]
fn shape_scaled_up_stays_sharp_without_the_switch() {
// The 10 px square scaled 800% spans 60.3..140.3.
let soft = run(false);
let sharp = run(true);
let ws = edge_width(&soft, 100, 50, 75);
let wc = edge_width(&sharp, 100, 50, 75);
assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}");
assert!(wc <= 1, "continuously rasterized edge: {wc}");
assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01);
for cr in [false, true] {
for via_parent in [false, true] {
let img = scaled_square(cr, via_parent);
let w = edge_width(&img, 100, 50, 75);
assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide");
assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01);
}
}
}
#[test]
@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() {
assert_eq!(k, 1.0, "text at {pos:?}");
}
}
/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as
/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like
/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751).
#[test]
fn effects_on_a_scaled_shape_follow_its_scale() {
let run = |size: f64, scale: f64, blur: f64| {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]);
p.next_id = next;
fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.props.sub_mut("effects").unwrap().children.push(fx.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let scaled = run(10.0, 800.0, 2.0);
let native = run(80.0, 100.0, 16.0);
let d = max_diff(&scaled, &native);
assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge");
assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}");
}

View File

@ -167,9 +167,10 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let div = if units_index { n.max(1) as f64 } else { 100.0 };
Range {
start: ctx.f(layer, sel, "start", 0.0) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div,
offset: ctx.f(layer, sel, "offset", 0.0) / div,
// Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%.
start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div,
offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div,
amount: af("amount", 100.0) / 100.0,
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
smoothness: af("smoothness", 100.0) / 100.0,

View File

@ -99,6 +99,30 @@ fn range_based_on_words_and_lines() {
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
#[test]
fn range_start_end_and_offset_are_capped() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
// End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing.
// Uncapped it would be -150%..150% and select everything.
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0));
set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0));
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
// Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing.
// Uncapped it would be -100%..150% and select everything.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]);
}
#[test]
fn selector_modes_and_randomize() {
let (mut p, cid, comp) = setup(400, 200);

View File

@ -457,9 +457,29 @@ pub struct Views3D {
/// Edited view cameras (views not present use their defaults).
#[serde(default)]
pub cams: BTreeMap<View3D, ViewCam>,
/// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2);
/// panes not present show their default ([`Views3D::DEFAULT_PANES`]).
#[serde(default)]
pub panes: Vec<View3D>,
}
impl Views3D {
/// Views the extra panes of a multi-view layout start with: Top, Front and Right.
pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right];
/// The view of extra pane `i` (0 = the first beside the main view).
pub fn pane(&self, i: usize) -> View3D {
self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default()
}
/// Choose the view of extra pane `i` (ignored past the third).
pub fn set_pane(&mut self, i: usize, v: View3D) {
if i >= Self::DEFAULT_PANES.len() {
return;
}
while self.panes.len() <= i {
self.panes.push(Self::DEFAULT_PANES[self.panes.len()]);
}
self.panes[i] = v;
}
pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam {
self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h))
}

View File

@ -26,3 +26,4 @@ boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-expr = { workspace = true }
effectcraft-psd = { workspace = true }

View File

@ -61,8 +61,24 @@ const TEXT: &[(&str, &str)] = &[
("animators", "ADBE Text Animators"),
];
// Match names of the six properties below Text ▸ Path Options, as named by
// After Effects' scripting API (rather than EffectCraft's internal ids).
const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[
("path", "ADBE Text Path"),
("reversePath", "ADBE Text Reverse Path"),
("perpendicular", "ADBE Text Perpendicular To Path"),
("forceAlignment", "ADBE Text Force Align Path"),
("firstMargin", "ADBE Text First Margin"),
("lastMargin", "ADBE Text Last Margin"),
];
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
const TEXT_SELECTORS: &[(&str, &str)] =
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
const TEXT_RANGE_SELECTOR: &[(&str, &str)] = &[("start", "ADBE Text Percent Start"), ("end", "ADBE Text Percent End"), ("offset", "ADBE Text Percent Offset")];
const CAMERA: &[(&str, &str)] = &[
("zoom", "ADBE Camera Zoom"),
("dof", "ADBE Camera Depth of Field"),
@ -411,7 +427,10 @@ pub fn node(chain: &[&Node]) -> String {
}
}
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "pathOptions" => find(TEXT_PATH_OPTIONS, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
@ -458,4 +477,15 @@ mod tests {
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
assert_eq!(shape_kind("nope"), None);
}
#[test]
fn text_range_selector_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, selectors, range, start, end, offset) =
(n("text"), n("animators"), n("animator"), n("selectors"), n("rangeSelector"), n("start"), n("end"), n("offset"));
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range]), "ADBE Text Selector");
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &start]), "ADBE Text Percent Start");
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &end]), "ADBE Text Percent End");
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &offset]), "ADBE Text Percent Offset");
}
}

View File

@ -6,8 +6,16 @@
var __ecGlobal = this;
// The scripting host keeps wrappers stable for each project item and comp/layer id.
// Clear them when loading a different document, whose IDs may be reused.
var __itemCache = Object.create(null), __layerCache = Object.create(null);
function __clearIdentityCache() {
__itemCache = Object.create(null);
__layerCache = Object.create(null);
}
function __call(id, p) {
var r = __exec(id, JSON.stringify(p || {}));
if (id === "file.newProject" || id === "file.open" || id === "file.revert") __clearIdentityCache();
return r === undefined || r === "" ? null : JSON.parse(r);
}
function __get(kind, a) {
@ -27,6 +35,25 @@ function __str(v) {
return String(v);
}
// ExtendScript includes the ES3 String.substr(start, length) method, which Boa
// does not currently provide. Follow its legacy length (not end-index) semantics.
if (typeof String.prototype.substr !== "function") {
Object.defineProperty(String.prototype, "substr", {
value: function (start, length) {
if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined");
var text = String(this), n = text.length;
var from = Number(start);
if (isNaN(from)) from = 0;
// ToIntegerOrInfinity, then clamp positive or negative offsets.
from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n);
var count = length === undefined ? n - from : Number(length);
if (isNaN(count) || count <= 0) return "";
return text.slice(from, from + count);
},
writable: true, configurable: true
});
}
// ------------------------------------------------------------------ enums
function __enum(name, keys, base) {
@ -67,6 +94,9 @@ var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGH
var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012);
var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212);
var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812);
// Accept the longer names used by some ExtendScript hosts.
ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS;
ImportAsType.COMPOSITION = ImportAsType.COMP;
var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012);
var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212);
var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0);
@ -179,7 +209,7 @@ File.prototype = {
get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); },
get parent() { return new Folder(this.path); },
get exists() { return JSON.parse(__file("exists", this.__path)) === true; },
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); },
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)).length : 0); },
get eof() { return this.__buf === null || this.__pos >= this.__buf.length; },
toString: function () { return this.__path; },
open: function (mode) {
@ -193,9 +223,9 @@ File.prototype = {
this.__buf = "";
return true;
}
this.__buf = JSON.parse(__file("read", this.__path));
this.__buf = JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path));
} else if (mode === "a") {
this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : "";
this.__buf = this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)) : "";
this.__pos = this.__buf.length;
} else {
// Check the gate now, like After Effects refusing to open a file for writing.
@ -232,18 +262,14 @@ File.prototype = {
tell: function () { return this.__pos; },
close: function () {
if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) {
__file("write", this.__path, this.__buf);
__file(this.encoding.toUpperCase() === "BINARY" ? "writeBinary" : "write", this.__path, this.__buf);
}
this.__buf = null;
this.__mode = null;
return true;
},
remove: function () { return JSON.parse(__file("remove", this.__path)); },
copy: function (to) {
var data = JSON.parse(__file("read", this.__path));
__file("write", __str(to), data);
return true;
},
copy: function (to) { return JSON.parse(__file("copy", this.__path, __str(to))); },
rename: function () { throw __err("File.rename is not supported"); },
execute: function () { return JSON.parse(__file("execute", this.__path)); },
openDlg: function () { return null; },
@ -903,6 +929,19 @@ Property.prototype.property = function () { return null; };
return __call(id, params);
};
P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); };
// EffectCraft extension: remove an inclusive range with one engine edit instead of
// cloning the project for every removed key. Indices are one-based as in removeKey.
P.removeKeys = function (first, last) {
var keys = this.__keys();
first = __num(first, "first key");
last = __num(last, "last key");
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first || last > keys.length)
throw __err("removeKeys: key range is out of bounds");
var targets = [];
for (var i = first; i <= last; i++)
targets.push({ layer: this.__layer.__id, prop: this.__uid, index: i - 1 });
return __call("keys.delete", { comp: this.__layer.__comp, __keys: targets });
};
P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; };
P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; };
// Values that don't interpolate (checkboxes, popups, text) only hold.
@ -1009,7 +1048,7 @@ MarkerProperty.prototype = {
t = __num(t, "time");
if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue");
var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 });
p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null;
p.cuePoint = (mv.cuePointName || mv.__params.length) ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null;
var l = this.__list(), at = -1;
for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i;
if (at >= 0) p.index = at;
@ -1025,14 +1064,18 @@ MarkerProperty.prototype = {
function __layer(compId, id) {
if (id === null || id === undefined) return null;
var i = __get("layer", { comp: compId, layer: id });
var key = compId + ":" + id;
if (Object.prototype.hasOwnProperty.call(__layerCache, key)) return __layerCache[key];
var i = __get("layer", { comp: compId, layer: id }), layer;
switch (i.kind) {
case "text": return new TextLayer(compId, id);
case "shape": return new ShapeLayer(compId, id);
case "camera": return new CameraLayer(compId, id);
case "light": return new LightLayer(compId, id);
default: return new AVLayer(compId, id);
case "text": layer = new TextLayer(compId, id); break;
case "shape": layer = new ShapeLayer(compId, id); break;
case "camera": layer = new CameraLayer(compId, id); break;
case "light": layer = new LightLayer(compId, id); break;
default: layer = new AVLayer(compId, id);
}
__layerCache[key] = layer;
return layer;
}
function Layer() {}
@ -1068,7 +1111,7 @@ Layer.prototype = {
set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); },
get index() { return this.__info().index; },
get matchName() { return this.__info().matchName; },
get containingComp() { return new CompItem(this.__comp); },
get containingComp() { return __item(this.__comp); },
get propertyDepth() { return 0; },
get propertyType() { return PropertyType.INDEXED_GROUP; },
get parentProperty() { return null; },
@ -1110,6 +1153,7 @@ Layer.prototype = {
__call("edit.label", this.__p({ label: n }));
},
get comment() { return this.__info().comment; },
set comment(v) { __call("layer.setComment", this.__p({ comment: String(v) })); },
get selected() { return this.__info().selected; },
set selected(v) {
var sel = this.containingComp.__info().selectedLayers;
@ -1177,6 +1221,11 @@ AVLayer.prototype.constructor = AVLayer;
var P = AVLayer.prototype;
function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); }
def("source", function () { return __item(this.__info().source); });
// After Effects exposes the most common Transform properties directly on AVLayer.
// Return the original Property object, not its current value: scripts can keyframe it.
["anchorPoint", "position", "scale", "rotation", "opacity"].forEach(function (name) {
def(name, function () { return this.transform[name]; });
});
def("width", function () { return this.__info().width; });
def("height", function () { return this.__info().height; });
def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); });
@ -1333,7 +1382,7 @@ LayerCollection.prototype = {
var ids = this.__ids(), sel = [];
for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]);
var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" });
return new CompItem(r.comp);
return __item(r.comp);
},
byName: function (name) {
var ids = this.__ids();
@ -1381,13 +1430,20 @@ ItemCollection.prototype = {
function __item(id) {
if (id === null || id === undefined) return null;
if (id === 0) return new FolderItem(0);
var i = __get("item", { id: id });
switch (i.kind) {
case "comp": return new CompItem(id);
case "folder": return new FolderItem(id);
default: return new FootageItem(id);
if (Object.prototype.hasOwnProperty.call(__itemCache, id)) return __itemCache[id];
var item;
if (id === 0) {
item = new FolderItem(0);
} else {
var i = __get("item", { id: id });
switch (i.kind) {
case "comp": item = new CompItem(id); break;
case "folder": item = new FolderItem(id); break;
default: item = new FootageItem(id);
}
}
__itemCache[id] = item;
return item;
}
function Item() {}
@ -1417,7 +1473,7 @@ Item.prototype = {
__call("project.select", { items: selection.filter(function (item) { return item !== id; }) });
}
},
get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); },
get usedIn() { return this.__info().usedIn.map(function (c) { return __item(c); }); },
remove: function () { __call("project.delete", { items: [this.__id] }); },
};
@ -1644,7 +1700,7 @@ RenderQueueItem.prototype = {
for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i];
throw __err("the render queue item no longer exists");
},
get comp() { return new CompItem(this.__info().comp); },
get comp() { return __item(this.__info().comp); },
get status() {
var s = this.__info().statusLabel;
for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k];
@ -1734,7 +1790,11 @@ function ImportOptions(file) {
this.rangeStart = 0;
this.rangeEnd = 0;
}
ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; };
ImportOptions.prototype.canImportAs = function (t) {
if (t === ImportAsType.FOOTAGE) return true;
return (t === ImportAsType.COMP || t === ImportAsType.COMP_CROPPED_LAYERS) &&
/\.(psd|psb|pdf|ai|eps)$/i.test(__str(this.file));
};
function Project() {}
Project.prototype = {
@ -1742,7 +1802,7 @@ Project.prototype = {
__info: function () { return __get("project"); },
get items() { return new ItemCollection(0); },
get numItems() { return this.__info().items.length; },
get rootFolder() { return new FolderItem(0); },
get rootFolder() { return __item(0); },
get activeItem() { return __item(this.__info().active); },
get selection() { return this.__info().selection.map(__item); },
get file() { var p = this.__info().path; return p ? new File(p) : null; },
@ -1788,10 +1848,19 @@ Project.prototype = {
if (!f) throw __err("importFile(): no file");
var args = { paths: [__str(f)] };
// After Effects imports just the picked file unless ImportOptions.sequence is set.
if (opts instanceof ImportOptions) args.sequence = !!opts.sequence;
if (opts instanceof ImportOptions) {
args.sequence = !!opts.sequence;
switch (opts.importAs) {
case ImportAsType.FOOTAGE: args.importAs = "footage"; break;
case ImportAsType.COMP: args.importAs = "composition"; break;
case ImportAsType.COMP_CROPPED_LAYERS: args.importAs = "compositionLayerSizes"; break;
default: throw __err("importFile(): unsupported ImportOptions.importAs value " + opts.importAs);
}
}
var r = __call("file.import", args);
if (r.errors && r.errors.length) throw __err(r.errors.join("; "));
return __item(r.items[0]);
// Composition imports create several footage items, but callers need the comp.
return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]);
},
importFileWithDialog: function () { return null; },
importPlaceholder: function (name, w, h, rate, dur) {
@ -1858,8 +1927,13 @@ var app = {
beginSuppressDialogs: function () {},
endSuppressDialogs: function () {},
purge: function () { __call("edit.purge", { what: "all" }); },
findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); },
findMenuCommandId: function (name) {
// Well-known After Effects ids must not shift when EffectCraft registers a command.
if (String(name) === "Undo") return 16;
return __get("menuCommandId", { name: String(name) });
},
executeCommand: function (id) {
if (id === 16) return __call("edit.undo", {});
var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id);
if (!cmd) throw __err("Unknown command id " + id);
return __call(cmd, {});

View File

@ -6,6 +6,7 @@ use std::mem::ManuallyDrop;
use std::sync::Arc;
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
use effectcraft_engine::history::History;
use effectcraft_engine::project::Project;
use effectcraft_engine::{ScriptRequest, Session};
use serde_json::{Value as J, json};
@ -63,8 +64,9 @@ impl Outcome {
pub(crate) struct Active {
pub(crate) session: Session,
pub(crate) output: Vec<String>,
/// Open undo groups: (name, project snapshot when the outermost group began).
pub(crate) groups: Vec<(String, Arc<Project>)>,
/// Open groups: name, project snapshot, original bounded history and saved-project
/// identity. The history snapshot preserves earlier steps if long groups hit the limit.
pub(crate) groups: Vec<(String, Arc<Project>, History, Arc<Project>)>,
pub(crate) name: String,
/// The script context running (its `app.scheduleTask` tasks are kept under this id, see
/// [`crate::tasks`]).
@ -240,12 +242,18 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
match op.as_str() {
"begin" => {
let snap = a.session.project.clone();
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
a.groups.push((
if name.is_empty() || name == "undefined" { "Script".into() } else { name },
snap,
a.session.history.clone(),
a.session.saved_project.clone(),
));
}
_ => {
if a.groups.len() == 1 {
let (name, snap) = a.groups.pop().unwrap_or_default();
collapse(&mut a.session, &name, &snap);
if let Some((name, snap, prior, saved)) = a.groups.pop() {
collapse(&mut a.session, &name, &snap, &prior, &saved);
}
} else {
a.groups.pop();
}
@ -257,17 +265,23 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
Ok(JsValue::undefined())
}
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
/// Fold every command in a group into a single bounded-history entry, even if the
/// oldest intermediate undo state was trimmed before the group ended.
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>, prior: &History, saved: &Arc<Project>) {
let h = &mut s.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
// Nothing was recorded (or history was reset by New / Open Project).
return;
};
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((name.to_string(), first));
h.merge_key = None;
if let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) {
// The group is short enough that its opening snapshot is still in history.
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((name.to_string(), first));
h.merge_key = None;
} else if !Arc::ptr_eq(&s.project, snap) && Arc::ptr_eq(&s.saved_project, saved) && !h.undo.is_empty() {
// The group exceeded Levels of Undo. Restore the pre-group history (including
// older steps and redo branches) and record one step for the *whole* group.
// A New / Open Project changes saved_project; never resurrect the old history.
*h = prior.clone();
h.record(name, snap.clone(), s.prefs.general.undo_levels.max(1) as usize);
}
}
/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see
@ -346,8 +360,14 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathB
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
}
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
/// names, `mkdir`, `remove`, `isDir`.
/// Encode a binary script string as bytes, without silently corrupting code points outside
/// the one-byte range supported by ExtendScript's BINARY encoding.
fn binary_bytes(data: &str) -> Result<Vec<u8>, String> {
data.chars().map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32))).collect()
}
/// `__file(op, path, data)`: text and one-byte-per-character BINARY reads/writes,
/// byte-for-byte copy, plus file and folder operations.
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let path = arg_string(args, 1, ctx)?;
@ -366,19 +386,36 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
}
"read" => {
"read" | "readBinary" => {
let p = check_access(s, &path, false)?;
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
Ok(json!(String::from_utf8_lossy(&bytes)))
if op == "readBinary" {
// Each byte becomes a single JS character (U+0000..U+00FF).
Ok(json!(bytes.into_iter().map(char::from).collect::<String>()))
} else {
Ok(json!(String::from_utf8_lossy(&bytes)))
}
}
"write" | "append" => {
"write" | "writeBinary" | "append" => {
let p = check_access(s, &path, true)?;
let ps = p.to_string_lossy().to_string();
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
bytes.extend_from_slice(data.as_bytes());
if op == "writeBinary" {
bytes.extend(binary_bytes(&data)?);
} else {
bytes.extend_from_slice(data.as_bytes());
}
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
Ok(json!(bytes.len()))
}
"copy" => {
let src = check_access(s, &path, false)?;
let dst = check_access(s, &data, true)?;
// Copy the original bytes, not UTF-8-decoded script text.
let bytes = s.services.read_file(&src.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
s.services.write_file(&dst.to_string_lossy(), &bytes).map_err(|e| format!("Unable to write {data}: {e}"))?;
Ok(json!(true))
}
"list" => {
let p = check_access(s, &path, false)?;
let mut names: Vec<String> = std::fs::read_dir(&p)
@ -591,8 +628,8 @@ impl Drop for Restore<'_> {
/// and take the output so far.
pub(crate) fn settle() -> Vec<String> {
with_active(|a| {
if let Some((name, snap)) = a.groups.first().cloned() {
collapse(&mut a.session, &name, &snap);
if let Some((name, snap, prior, saved)) = a.groups.first().cloned() {
collapse(&mut a.session, &name, &snap, &prior, &saved);
a.groups.clear();
}
a.session.sanitize_state();

View File

@ -730,3 +730,283 @@ fn import_options_sequence_picks_one_still_or_the_run() {
assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn binary_file_io_preserves_all_byte_values() {
let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("sig.bin");
let dst = dir.join("written.bin");
let copied = dir.join("copied.bin");
let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a];
std::fs::write(&src, original).unwrap();
let mut s = session();
s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string());
s.prefs.scripting.allow_scripts_write_files = true;
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
let read = ok(
&mut s,
&format!(
"var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]",
q(&src)
),
);
assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff]));
ok(
&mut s,
&format!(
"var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();",
q(&dst)
),
);
assert_eq!(std::fs::read(&dst).unwrap(), original);
let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied)));
assert_eq!(copy.result, json!(true));
assert_eq!(std::fs::read(&copied).unwrap(), original);
ok(&mut s, &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();", q(&dst)));
assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat());
let invalid = run_code(
&mut s,
&format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)),
"invalid-binary.jsx",
);
assert!(invalid.error.is_some(), "code points above 255 must not be truncated");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn script_undo_uses_stable_after_effects_command_id() {
let mut s = session();
let lookup = ok(&mut s, "app.findMenuCommandId('Undo')");
assert_eq!(lookup.result, json!(16));
// ID 16 used to index an unrelated command as the registry grew. It must
// undo the preceding layer creation rather than dispatching by position.
let r = ok(&mut s, "var c = app.project.items.addComp('Undo ID', 100, 100, 1, 1, 24); c.layers.addNull(); app.executeCommand(16); c.numLayers");
assert_eq!(r.result, json!(0));
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
// Lookup of other command names still uses the existing registry.
assert!(ok(&mut s, "app.findMenuCommandId('New Project')").result.as_u64().unwrap_or(0) > 0);
}
#[test]
fn long_script_undo_group_counts_as_one_level() {
let mut s = session();
s.prefs.general.undo_levels = 3;
ok(&mut s, "var c = app.project.items.addComp('Long Undo', 100, 100, 1, 1, 24); c.layers.addNull();");
assert_eq!(s.history.undo.len(), 2);
// More edits than the history cap must still be one undoable group.
ok(&mut s, "var c = app.project.item(1); app.beginUndoGroup('Forty layers'); for (var i = 0; i < 40; i++) c.layers.addNull(); app.endUndoGroup();");
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 41);
assert_eq!(s.history.undo.len(), 3);
assert_eq!(s.history.undo[2].0, "Forty layers");
s.undo();
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 1);
// Entries preceding the group remain available instead of being trimmed by
// the forty intermediate operations.
s.undo();
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
}
#[test]
fn script_remove_keys_batches_a_range_as_one_edit() {
let mut s = session();
let o = ok(
&mut s,
"var c = app.project.items.addComp('Batch Keys', 100, 100, 1, 10, 25); var p = c.layers.addNull().transform.rotation; for (var i = 0; i < 5; i++) p.setValueAtTime(i, i * 10); p.removeKeys(2, 4); [p.numKeys, p.keyTime(1), p.keyTime(2)]",
);
assert_eq!(o.result, json!([2, 0, 4]));
// The entire deletion is one undo step; keys outside the range survive.
s.undo();
let restored = ok(&mut s, "app.project.item(1).layer(1).transform.rotation.numKeys");
assert_eq!(restored.result, json!(5));
let invalid = run_code(&mut s, "app.project.item(1).layer(1).transform.rotation.removeKeys(0, 2)", "remove-keys-range.jsx");
assert!(invalid.error.is_some());
}
#[test]
fn script_layer_comment_and_unnamed_marker_params_round_trip() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Notes", 100, 100, 1, 5, 25);
var l = c.layers.addSolid([1, 0, 0], "Solid", 10, 10, 1);
l.comment = "Saved layer note";
var markers = l.property("ADBE Marker");
var plain = new MarkerValue("Plain");
plain.setParameters({ lml: "shot", index: "1" });
markers.setValueAtTime(1, plain);
var named = new MarkerValue("Named");
named.cuePointName = "cue";
named.setParameters({ lml: "shot", index: "2" });
markers.setValueAtTime(2, named);
[l.comment, markers.keyValue(1).getParameters().index,
markers.keyValue(1).cuePointName, markers.keyValue(2).getParameters().index]
"#,
);
assert_eq!(o.result, json!(["Saved layer note", "1", "", "2"]));
}
#[test]
fn script_av_layer_transform_shortcuts_return_live_properties() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Shortcuts", 100, 100, 1, 5, 25);
var l = c.layers.addSolid([1, 0, 0], "S", 10, 10, 1);
l.position.setValue([30, 40]);
l.rotation.setValueAtTime(1, 45);
[l.position.value[0], l.transform.position.value[1], l.rotation.numKeys,
l.opacity === undefined, l.scale === undefined, l.anchorPoint === undefined]
"#,
);
assert_eq!(o.result, json!([30, 40, 1, false, false, false]));
s.undo();
let restored = ok(&mut s, "app.project.item(1).layer(1).rotation.numKeys");
assert_eq!(restored.result, json!(0));
}
#[test]
fn extendscript_substr_keeps_start_and_length_semantics() {
let mut s = session();
let o = ok(
&mut s,
r#"[
"abcdef".substr(0, 2),
"abcdef".substr(2, 3),
"abcdef".substr(-2),
"abcdef".substr(-20, 2),
"abcdef".substr(2, 0),
"abcdef".substr(2, -3),
"abcdef".substr(2.9, 2.9),
"abcdef".substr(3),
"abcdef".substr(30, 2),
"abcdef".substr(NaN, 2),
"abcdef".substr(2, NaN)
]"#,
);
assert_eq!(o.result, json!(["ab", "cde", "ef", "ab", "", "", "cd", "def", "", "ab", ""]));
}
#[test]
fn import_options_preserve_layered_psd_compositions() {
use effectcraft_psd::Rect;
use effectcraft_psd::write::{WDoc, WLayer, write};
let dir = std::env::temp_dir().join(format!("ec-script-import-as-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("layers.psd");
let mut doc = WDoc::new(12, 12);
doc.layers.push(WLayer::solid("Background", Rect::new(0, 0, 12, 12), [1.0, 1.0, 1.0, 1.0]));
doc.layers.push(WLayer::solid("Accent", Rect::new(2, 2, 4, 4), [0.0, 0.4, 1.0, 1.0]));
std::fs::write(&source, write(&doc)).unwrap();
let file = serde_json::to_string(&source.to_string_lossy()).unwrap();
let mut s = session();
let o = ok(
&mut s,
&format!(
"var io = new ImportOptions(new File({file})); io.importAs = ImportAsType.COMPOSITION_CROPPED_LAYERS; \
[io.canImportAs(io.importAs), app.project.importFile(io).typeName]"
),
);
assert_eq!(o.result, json!([true, "Composition"]));
let imported = s.project.comps().next().unwrap().1;
assert_eq!(imported.layers.len(), 2);
let bad = ok(&mut s, &format!("new ImportOptions(new File({file})).canImportAs(ImportAsType.PROJECT)"));
assert_eq!(bad.result, json!(false));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn script_item_and_layer_identity_survives_repeated_access() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Identity", 120, 80, 1, 3, 25);
var parent = c.layers.addNull(), child = c.layers.addNull();
child.parent = parent;
[
parent.containingComp === c,
c.layer(1) === c.layer(1),
child.parent === parent,
app.project.item(1) === app.project.item(1),
app.project.item(1) === c,
app.project.itemByID(c.id) === c
]
"#,
);
assert_eq!(o.result, json!([true, true, true, true, true, true]));
// Reusing item and layer IDs in a new project must not alias old wrappers.
let o = ok(
&mut s,
r#"
var old = app.project.item(1);
app.newProject();
var next = app.project.items.addComp("New", 120, 80, 1, 3, 25);
[old !== next, app.project.item(1) === next]
"#,
);
assert_eq!(o.result, json!([true, true]));
}
#[test]
fn script_text_path_options_expose_after_effects_match_names() {
let mut s = session();
let r = ok(
&mut s,
r#"
var c = app.project.items.addComp("Text Path", 160, 90, 1, 2, 24);
var t = c.layers.addText("On a path");
var options = t.property("ADBE Text Properties").property("ADBE Text Path Options");
var names = [];
for (var i = 1; i <= options.numProperties; i++)
names.push(options.property(i).matchName);
[names, options.property("ADBE Text Path").matchName,
options.property("ADBE Text Perpendicular To Path").matchName]
"#,
);
assert_eq!(
r.result,
json!([
[
"ADBE Text Path",
"ADBE Text Reverse Path",
"ADBE Text Perpendicular To Path",
"ADBE Text Force Align Path",
"ADBE Text First Margin",
"ADBE Text Last Margin"
],
"ADBE Text Path",
"ADBE Text Perpendicular To Path"
])
);
}
/// A scripted text animator has only the selectors the script adds, and the Range Selector and
/// its Start/End/Offset resolve by After Effects match name (#837).
#[test]
fn scripted_text_range_selector_resolves_and_is_the_only_selector() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Sel", 960, 300, 1, 3, 30);
var t = c.layers.addText("MOVARA");
var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator");
a.property("ADBE Text Animator Properties").addProperty("ADBE Text Opacity").setValue(0);
var sels = a.property("ADBE Text Selectors");
var sel = sels.addProperty("ADBE Text Selector");
sel.property("ADBE Text Percent Start").setValue(50);
var end = sel.property("ADBE Text Percent End").value;
[sels.numProperties, sel.matchName, sel.property("start").value, end, sel.property("ADBE Text Percent Offset").matchName]
"#,
);
assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"]));
}

View File

@ -794,10 +794,10 @@ impl<'a, 'i> Parser<'a, 'i> {
let vb = viewbox_transform(n, w, h);
let mut children = vec![];
let saved = (self.vw, self.vh);
if let Some((_, vw, vh)) = viewbox(n) {
self.vw = vw;
self.vh = vh;
}
// Percentages use this viewport's user space, even without a viewBox.
let (vw, vh) = viewbox(n).map_or((w, h), |(_, vw, vh)| (vw, vh));
self.vw = vw;
self.vh = vh;
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}

View File

@ -86,6 +86,39 @@ fn paths_gradients_css_use() {
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
}
#[test]
fn nested_svg_percentages_use_the_nearest_viewport() {
let d = parse(
br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80" viewBox="0 0 100 80">
<svg x="10" y="10" width="40" height="20">
<rect id="inner" width="50%" height="50%" fill="red"/>
<svg x="50%" width="50%" height="50%">
<rect id="deep" width="50%" height="50%" fill="blue"/>
</svg>
</svg>
<rect id="outer" y="60" width="50%" height="10" fill="green"/>
<svg x="60" width="40" height="20" viewBox="0 0 200 100">
<rect id="viewbox" width="50%" height="50%"/>
</svg>
</svg>"#,
)
.unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 4);
for ((_, _, shape), (name, width, height)) in
shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)])
{
assert_eq!(shape.name, name);
assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom);
}
let img = rasterize(&d, 100, 80, 1.0);
assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&img, 35, 17)[3], 0.0);
assert_eq!(px(&img, 45, 65)[3], 1.0);
assert_eq!(px(&img, 55, 65)[3], 0.0);
}
#[test]
fn malformed_input() {
assert!(parse(b"<html/>").is_err());

View File

@ -0,0 +1,374 @@
//! The font folders of a Linux/BSD system, found the way the desktop finds them: the usual
//! folders, the XDG data folders (`$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`), the host font
//! folders a Flatpak sandbox exposes under `/run/host`, and the `<dir>` entries of the fontconfig
//! configuration (`/etc/fonts/fonts.conf` with its includes, `~/.config/fontconfig/fonts.conf`).
//!
//! The fontconfig files are read with a small tag scanner (no fontconfig library, no XML crate):
//! only `<dir>`, `<remap-dir>` and `<include>` matter here. The work is bounded: include depth,
//! number and size of the files read, and every file is read at most once.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// The environment the folders depend on, passed in so tests don't touch the process environment.
#[derive(Clone, Debug, Default)]
pub(crate) struct FontEnv {
pub home: Option<PathBuf>,
pub xdg_data_home: Option<PathBuf>,
pub xdg_data_dirs: Option<std::ffi::OsString>,
pub xdg_config_home: Option<PathBuf>,
/// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones.
pub fontconfig_file: Option<PathBuf>,
/// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists).
pub fontconfig_roots: Vec<PathBuf>,
/// Folders scanned if they exist (the usual system folders and the Flatpak host folders).
pub fixed: Vec<PathBuf>,
}
impl FontEnv {
pub fn from_process() -> Self {
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
Self {
home: var("HOME").map(PathBuf::from),
xdg_data_home: var("XDG_DATA_HOME").map(PathBuf::from),
xdg_data_dirs: var("XDG_DATA_DIRS"),
xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from),
fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from),
fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(),
fixed: [
"/usr/share/fonts",
"/usr/local/share/fonts",
// Flatpak: the host's system, local and user fonts (no filesystem permission needed).
"/run/host/fonts",
"/run/host/local-fonts",
"/run/host/user-fonts",
]
.map(PathBuf::from)
.to_vec(),
}
}
fn data_home(&self) -> Option<PathBuf> {
self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share")))
}
fn config_home(&self) -> Option<PathBuf> {
self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config")))
}
}
/// Every font folder for `env`, without duplicates, in a stable order. Folders that don't exist
/// are kept (the scan skips them).
pub(crate) fn unix_font_dirs(env: &FontEnv) -> Vec<PathBuf> {
let mut dirs = env.fixed.clone();
if let Some(h) = &env.home {
dirs.push(h.join(".local/share/fonts"));
dirs.push(h.join(".fonts"));
}
if let Some(d) = env.data_home() {
dirs.push(d.join("fonts"));
}
let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| p.join("fonts")));
dirs.extend(fontconfig_dirs(env));
dedup_dirs(dirs)
}
/// Drop repeated folders (also the same folder reached through a symlink), keeping the first.
fn dedup_dirs(dirs: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
dirs.into_iter().filter(|d| seen.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()))).collect()
}
const MAX_INCLUDE_DEPTH: u32 = 8;
const MAX_FILES: usize = 512;
const MAX_FILE_BYTES: u64 = 1 << 20;
/// The font folders named by the fontconfig configuration.
pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() };
let root = match &env.fontconfig_file {
Some(f) => Some(f.clone()),
None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(),
};
if let Some(root) = root {
st.file(&root, 0);
}
// The user's own configuration, in case the system one doesn't include it.
if let Some(c) = env.config_home() {
st.include(&c.join("fontconfig/fonts.conf"), 0);
st.include(&c.join("fontconfig/conf.d"), 0);
}
st.out
}
struct Reader<'a> {
env: &'a FontEnv,
out: Vec<PathBuf>,
seen: HashSet<PathBuf>,
}
impl Reader<'_> {
/// Read a configuration file or, for a folder, its `NN-name.conf` files in name order.
fn include(&mut self, path: &Path, depth: u32) {
if path.is_dir() {
let Ok(rd) = std::fs::read_dir(path) else { return };
let mut files: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())))
.collect();
files.sort();
for f in files {
self.file(&f, depth);
}
} else {
self.file(path, depth);
}
}
fn file(&mut self, path: &Path, depth: u32) {
if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() {
return;
}
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !self.seen.insert(key) {
return;
}
if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
return;
}
let Ok(text) = std::fs::read_to_string(path) else { return };
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
for el in elements(&text) {
match el.name {
"dir" | "remap-dir" => {
if let Some(p) = self.resolve(&el, &base, false) {
self.out.push(p);
}
}
"include" => {
if let Some(p) = self.resolve(&el, &base, true) {
self.include(&p, depth.saturating_add(1));
}
}
_ => {}
}
}
}
/// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders)
/// or config (includes) home, `prefix="relative"` and plain relative paths the including
/// file's folder, `prefix="default"`/`"cwd"` the working folder.
fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option<PathBuf> {
let v = el.text.trim();
if v.is_empty() {
return None;
}
if let Some(rest) = v.strip_prefix('~') {
return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/')));
}
let p = Path::new(v);
if p.is_absolute() {
return Some(p.to_path_buf());
}
match el.attr("prefix") {
Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)),
Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)),
_ => Some(base.join(p)),
}
}
}
struct Element<'a> {
name: &'a str,
attrs: &'a str,
text: String,
}
impl Element<'_> {
fn attr(&self, key: &str) -> Option<&str> {
let mut rest = self.attrs;
while let Some(eq) = rest.find('=') {
let k = rest.get(..eq)?.trim();
let after = rest.get(eq + 1..)?.trim_start();
let q = after.chars().next()?;
if q != '"' && q != '\'' {
return None;
}
let body = after.get(1..)?;
let end = body.find(q)?;
if k == key {
return body.get(..end);
}
rest = body.get(end + 1..)?;
}
None
}
}
/// The `<dir>`, `<remap-dir>` and `<include>` elements of a fontconfig file, comments skipped.
fn elements(src: &str) -> Vec<Element<'_>> {
let mut out = Vec::new();
let mut i = 0;
while let Some(off) = src.get(i..).and_then(|s| s.find('<')) {
let start = i + off;
let Some(tail) = src.get(start..) else { break };
if tail.starts_with("<!--") {
match tail.find("-->") {
Some(e) => {
i = start + e + 3;
continue;
}
None => break,
}
}
let Some(close) = tail.find('>') else { break };
let inner = tail.get(1..close).unwrap_or_default();
i = start + close + 1;
let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len());
let name = inner.get(..name_len).unwrap_or_default();
if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') {
continue;
}
let attrs = inner.get(name_len..).unwrap_or_default();
let end_tag = format!("</{name}>");
let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break };
let body = src.get(i..i + body_len).unwrap_or_default();
i += body_len + end_tag.len();
out.push(Element { name, attrs, text: decode_entities(body) });
}
out
}
fn decode_entities(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-font-dirs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn env_in(root: &Path) -> FontEnv {
FontEnv {
home: Some(root.join("home")),
fontconfig_roots: vec![root.join("etc/fonts/fonts.conf")],
xdg_data_dirs: Some("/nonexistent-share".into()),
..Default::default()
}
}
fn write(p: &Path, s: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, s).unwrap();
}
#[test]
fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() {
let root = temp("conf");
let etc = root.join("etc/fonts");
write(
&etc.join("fonts.conf"),
r#"<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<!-- <dir>/commented/out</dir> -->
<dir>/sys/fonts</dir>
<dir prefix="xdg">fonts</dir>
<dir>~/.fonts</dir>
<cachedir>/var/cache/fontconfig</cachedir>
<cachedir prefix="xdg">fontconfig</cachedir>
<dir prefix="relative">local-fonts</dir>
<dir salt="x">/a&amp;b/fonts</dir>
<include ignore_missing="yes">conf.d</include>
<include ignore_missing="yes">missing.conf</include>
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
<include>fonts.conf</include>
</fontconfig>"#,
);
write(
&etc.join("conf.d/50-flatpak.conf"),
"<fontconfig><reset-dirs /><dir>/run/host/fonts</dir><include ignore_missing=\"yes\">/run/host/font-dirs.xml</include></fontconfig>",
);
write(&etc.join("conf.d/60-remap.conf"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/remapped/fonts</remap-dir></fontconfig>");
write(&etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
write(&etc.join("conf.d/nodigit.conf"), "<fontconfig><dir>/no/digit</dir></fontconfig>");
write(&root.join("home/.config/fontconfig/fonts.conf"), "<fontconfig><dir>/user/conf/fonts</dir></fontconfig>");
let dirs = fontconfig_dirs(&env_in(&root));
let home = root.join("home");
let want = vec![
PathBuf::from("/sys/fonts"),
home.join(".local/share/fonts"),
home.join(".fonts"),
etc.join("local-fonts"),
PathBuf::from("/a&b/fonts"),
PathBuf::from("/run/host/fonts"),
PathBuf::from("/remapped/fonts"),
PathBuf::from("/user/conf/fonts"),
];
assert_eq!(dirs, want);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn include_cycles_and_unclosed_tags_terminate() {
let root = temp("cycle");
let etc = root.join("etc/fonts");
write(&etc.join("fonts.conf"), "<fontconfig><dir>/one</dir><include>b.conf</include><include>fonts.conf</include></fontconfig>");
write(&etc.join("b.conf"), "<fontconfig><include>fonts.conf</include><dir>/two</dir><dir>/unclosed");
let dirs = fontconfig_dirs(&env_in(&root));
assert_eq!(dirs, vec![PathBuf::from("/one"), PathBuf::from("/two")]);
assert!(fontconfig_dirs(&FontEnv::default()).is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn xdg_and_flatpak_folders_are_listed_once() {
let root = temp("xdg");
let env = FontEnv {
home: Some(root.join("home")),
xdg_data_home: Some(root.join("data")),
xdg_data_dirs: Some(std::env::join_paths([root.join("share"), "relative/ignored".into()]).unwrap()),
fixed: vec![PathBuf::from("/run/host/fonts")],
fontconfig_file: Some(root.join("fonts.conf")),
..Default::default()
};
write(&root.join("fonts.conf"), "<fontconfig><dir>/run/host/fonts</dir><dir prefix=\"xdg\">fonts</dir></fontconfig>");
let dirs = unix_font_dirs(&env);
assert_eq!(
dirs,
vec![
PathBuf::from("/run/host/fonts"),
root.join("home/.local/share/fonts"),
root.join("home/.fonts"),
root.join("data/fonts"),
root.join("share/fonts"),
]
);
let _ = std::fs::remove_dir_all(&root);
}
/// A font in a folder that only the fontconfig configuration names (as in a Flatpak sandbox,
/// where the host's fonts are under /run/host) is found by the scan.
#[test]
fn a_font_named_only_by_fontconfig_is_scanned() {
use crate::fonts::FontSource;
let root = temp("e2e");
let host = root.join("run/host/fonts/TTF");
std::fs::create_dir_all(&host).unwrap();
std::fs::write(host.join("NOTOSERIF.TTF"), crate::fonts::NOTO_SERIF_REGULAR).unwrap();
write(&root.join("etc/fonts/conf.d/50-flatpak.conf"), &format!("<fontconfig><dir>{}</dir></fontconfig>", root.join("run/host/fonts").display()));
write(&root.join("etc/fonts/fonts.conf"), "<fontconfig><include ignore_missing=\"yes\">conf.d</include></fontconfig>");
let src = crate::fonts::DirectorySource { dirs: unix_font_dirs(&env_in(&root)) };
let faces = src.faces();
assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::<Vec<_>>());
let _ = std::fs::remove_dir_all(&root);
}
}

View File

@ -89,13 +89,11 @@ impl DirectorySource {
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else if cfg!(unix) && !cfg!(target_arch = "wasm32") {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
if let Some(h) = &home {
dirs.push(h.join(".local/share/fonts"));
dirs.push(h.join(".fonts"));
}
}
// Linux/BSD: the usual folders, the XDG data folders, a Flatpak sandbox's host font
// folders (/run/host/…) and the folders the fontconfig configuration names.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
dirs.extend(crate::font_dirs::unix_font_dirs(&crate::font_dirs::FontEnv::from_process()));
dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref()));
Self { dirs }
}
@ -112,7 +110,8 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
if depth < 4 {
// Bounded, so a symlink loop ends.
if depth < 8 {
walk(&p, depth + 1, out);
}
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {

View File

@ -7,6 +7,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod font_dirs;
pub mod fonts;
pub mod layout;
pub mod path_text;

View File

@ -223,8 +223,16 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
if let Some(z) = v.get("zoom") {
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
}
// The active comp's Resolution (`view.res.*`), saved with the project.
if let Some(r) = v.get("res").and_then(Value::as_str) {
app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).unwrap_or(app.ui.viewer.res);
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
};
if let Some(cid) = app.session.active_comp_id()
&& let Err(e) = app.session.set_resolution(cid, res)
{
return err(e);
}
}
if let Some(Value::Array(a)) = v.get("pan") {
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
@ -308,6 +316,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
Err(e) => return err(e),
};
let m = modifiers(p);
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
// and the synthetic events arrive one per frame: hold them for the whole gesture.
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
@ -321,6 +332,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
}
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.drag" => {
@ -332,6 +344,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
for i in 1..=steps {
@ -339,6 +352,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
}
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.scroll" => {
@ -522,11 +536,16 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str());
json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates})
});
let mut ui = json!(app.ui);
// The viewer's Resolution is the active comp's (kept in the project).
if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) {
v.insert("res".into(), json!(app.viewer_res()));
}
json!({
"window": [size.x, size.y],
"pixelsPerPoint": ctx.pixels_per_point(),
"fps": app.fps,
"ui": app.ui,
"ui": ui,
"playing": app.playback.playing,
"time": app.session.time().seconds(),
"activeComp": app.session.state.active_comp.map(|i| i.0),

View File

@ -670,6 +670,37 @@ pub struct Floating {
pub rect: [f32; 4],
}
/// A floating group moved out of the main window into its own native window (which can sit on
/// another monitor): the group holding `panel` shows there.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PanelWindow {
pub panel: PanelKind,
/// The window's [x, y] outer position and [w, h] inner size, in desktop points.
pub rect: [f32; 4],
}
/// The smallest and largest native panel window, in points.
pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0];
pub const PANEL_WINDOW_MAX: f32 = 16_384.0;
/// The farthest a native panel window may sit from the desktop origin, in points.
pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0;
impl PanelWindow {
/// The rect made safe to hand to the windowing system: finite, with a size within
/// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within
/// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included).
pub fn sanitized(rect: [f32; 4]) -> [f32; 4] {
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
let [x, y, w, h] = rect;
[
finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX),
finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX),
]
}
}
impl DockNode {
/// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it
/// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s
@ -1305,10 +1336,13 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
}
}
}
// clicking anywhere in the panel focuses it
// clicking anywhere in the panel focuses it; a press on another of its tabs waits for that
// tab's click, which focuses the panel actually chosen (a text edit stays open in Character)
let at = ui.input(|i| i.pointer.interact_pos());
if let Some(p) = active_panel
&& ui.rect_contains_pointer(g.rect)
&& ui.input(|i| i.pointer.any_pressed())
&& !out.tabs.iter().any(|(q, r)| *q != p && at.is_some_and(|a| r.contains(a)))
{
out.actions.push(DockAction::Focus(p));
}

Some files were not shown because too many files have changed in this diff Show More