diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4499e3fdc..2a8144a5b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,7 +29,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: effectcraft app-dir: EffectCraft diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 77a9cc432..c97d32ed9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -89,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall `crates/engine/presets/transitions.json` (sidecar `.attribution`). - Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`). +- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker): + `crates/engine/presets/behaviors.json` (sidecar `.attribution`). - Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..74d946f01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. +On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA +pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime +library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile +ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking +error; `cargo xtask ci` probes pkg-config up front and names the package: + +```sh +sudo dnf install alsa-lib-devel # Fedora +sudo apt install libasound2-dev # Debian / Ubuntu +sudo pacman -S alsa-lib # Arch +``` + +If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc` +whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs +that variable set. + ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace cargo xtask ci # what every commit must pass ``` +The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces +still name the file and line, but a debugger cannot show local variables. For an interactive +debugger session use the `dev-full` profile, which is the same build with full debug info: + +```sh +cargo run --profile dev-full -p effectcraft -- --demo +``` + ## Ground rules - **Clean room.** Work from After Effects' public documentation and its observable behaviour only. diff --git a/Cargo.lock b/Cargo.lock index 52812471c..a48f2d44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,7 @@ name = "effectcraft-media" version = "0.18.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1653,6 +1654,7 @@ version = "0.18.0" dependencies = [ "effectcraft-geom", "image", + "log", "miniz_oxide 0.9.1", "serde", "serde_json", @@ -7198,6 +7200,7 @@ name = "xtask" version = "0.18.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index aa917fc63..dee46306d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,6 +153,13 @@ opt-level = 1 # Panics and backtraces keep file and line; full debug info only made the many test binaries large. debug = "line-tables-only" +# Same settings as `dev`, but with full debug info for interactive debugger sessions: local +# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging; +# everything else stays on the fast default. See CONTRIBUTING.md, "Setup". +[profile.dev-full] +inherits = "dev" +debug = true + # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] opt-level = 3 diff --git a/README.md b/README.md index 81d42360c..601f268b0 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ a few places where it matters to us: without a plugin. - **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly in version control. -- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one - command registry, so the same actions are reachable from a command line, a JSON control - channel and an MCP server for agents. +- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as + commands. Engine commands are available through the CLI, JSON control channel and MCP; + UI-only commands use the frontend dispatcher and can be invoked through the control channel + or an attached MCP bridge. - **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and EffectCraft's own VP9, AV1, HEVC and Opus encoders. @@ -155,7 +156,7 @@ tools, and Front, Top, Left and Custom views. A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and **ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1** -MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field +MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and resize, Render Settings and Output Module templates with defaults, post-render actions, storage overflow and render logs. The same queue runs from the command line. Every encoder is pure @@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. ## Built for agents -Everything you can do from a menu is a command with an id, and agents can reach every one of -them: +Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands +need the running app's frontend dispatcher: complete menu access requires the app's control +channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands, +but not UI-only commands or bridge-only `ui_*` tools. - **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..1ce9d9bb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,6 +117,9 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain + that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it + starts off, so existing shapes behave as before (#2489). - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6e5b8d6cc..5aedfad58 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -7,11 +7,12 @@ //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order //! effectcraft-cli run --args-file F the same, one argument per line of F +//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] //! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent] -//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S] +//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S] //! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba] //! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] @@ -98,6 +99,10 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order run --args-file F the same, one argument per line of F (no command-line length limit) + run --commands FILE|- steps from FILE, or standard input for -: a JSON array of + {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one + such object per line (JSON Lines); at most 64 MiB and 100000 steps. + Repeatable; run after the pairs and --args-file props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -107,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli ) -> Result raw, Err(e) => fail_usage(&e), @@ -334,6 +343,87 @@ impl From for Failure { } } +/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input. +const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024; +/// Most steps one `run --commands` takes. +const MAX_COMMANDS_STEPS: usize = 100_000; + +/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past +/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from +/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum. +fn read_commands(source: &str) -> Result { + use std::io::Read; + let mut bytes = vec![]; + let read = if source == "-" { + std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes) + } else { + std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)) + }; + read.map_err(|e| format!("--commands {source}: {e}"))?; + if bytes.len() as u64 > MAX_COMMANDS_BYTES { + return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024))); + } + String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text")) +} + +/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params": +/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank +/// lines skipped). `first` is the run's index of the first of them, which an error names with the +/// entry's place in the text. +fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> { + // Notepad on Windows starts UTF-8 files with a byte order mark. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps"); + // Each entry with where it is in the text: its line for JSON Lines. + let mut entries: Vec<(Option, Value)> = vec![]; + if text.trim_start().starts_with('[') { + let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?; + let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) }; + if items.len() > MAX_COMMANDS_STEPS { + return Err(too_many()); + } + entries.extend(items.into_iter().map(|v| (None, v))); + } else { + for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + if entries.len() == MAX_COMMANDS_STEPS { + return Err(too_many()); + } + let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?; + entries.push((Some(n + 1), v)); + } + } + entries + .into_iter() + .enumerate() + .map(|(k, (line, v))| { + command_step(v).map_err(|e| { + let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}")); + format!("--commands {source}: step {} ({place}): {e}", first + k) + }) + }) + .collect() +} + +/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`. +fn command_step(v: Value) -> Result<(String, Value), String> { + let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) }; + let id = match (o.remove("command"), o.remove("id")) { + (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id, + (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()), + (None, None) => return Err("no `command`".into()), + _ => return Err("`command` must be a string, a command id".into()), + }; + let params = match o.remove("params") { + None | Some(Value::Null) => json!({}), + Some(p @ Value::Object(_)) => p, + Some(_) => return Err(format!("`params` of {id} must be a JSON object")), + }; + if let Some(key) = o.keys().next() { + return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`")); + } + Ok((id, params)) +} + fn usage_err(m: &str) -> Result { Err(Failure::Usage(m.to_string())) } @@ -524,14 +614,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); } - if pairs.is_empty() { - return usage_err("run needs [] pairs"); + let sources = args.opts("--commands"); + if pairs.is_empty() && sources.is_empty() { + return usage_err("run needs [] pairs or --commands FILE|-"); } - let mut b = backend(args, true)?; - let mut results = vec![]; + let mut steps = vec![]; let mut i = 0; while i < pairs.len() { - let id = &pairs[i]; + let id = pairs[i].clone(); let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) { Some(p) => { i += 1; @@ -540,9 +630,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { None => json!({}), }; i += 1; - let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; - if let Some(e) = failed_render(id, &r) { - return Err(Failure::Error(format!("{id}: {e}"))); + steps.push((id, params)); + } + if sources.iter().filter(|s| **s == "-").count() > 1 { + return usage_err("--commands - is given twice: standard input is read once"); + } + for &source in &sources { + let text = read_commands(source).map_err(Failure::Usage)?; + steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?); + } + if steps.is_empty() { + return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", ")))); + } + let mut b = backend(args, true)?; + let mut results = vec![]; + // The step's index closes the message: a generated list repeats ids, and callers match + // the leading `: `. + for (n, (id, params)) in steps.into_iter().enumerate() { + let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(format!("{id}: {e} (step {n})"))); } if !json_out { emit(&r, false); diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index c7a905e58..bcab79c8b 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -133,6 +133,78 @@ fn run_reads_arguments_from_a_file() { assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt")); } +/// Run with `stdin` piped in; returns (exit code, stdout, stderr). +fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) { + let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON +/// Lines, from a file or standard input, and runs them as the same positional pairs would: after +/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are +/// over Linux's 128 KiB per-argument limit goes through. +#[test] +fn run_reads_commands_from_a_file_or_stdin() { + let steps = [ + ("comp.new", json!({"name": "A", "width": 64, "height": 64})), + ("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})), + ("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})), + ("project.summary", json!({})), + ]; + let pairs: Vec = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect(); + let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect(); + let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat()); + let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64(); + assert_eq!(layers(&expected), Some(2), "{expected}"); + + let objects: Vec = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect(); + let array = tmp("commands.json"); + std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap(); + assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected); + + let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect(); + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines); + assert_eq!(code, 0, "{err}"); + assert_eq!(serde_json::from_str::(&out).unwrap(), expected); + + // Positional pairs first, then the file. + let rest = tmp("commands-rest.jsonl"); + std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::()).unwrap(); + assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected); + + let name = "n".repeat(140 * 1024); + let big = tmp("commands-big.jsonl"); + std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap(); + let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]); + assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len())); + + for (input, args, error) in [ + ("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"), + ( + "{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n", + &["run", "project.summary", "{}", "--commands", "-", "--empty"], + "step 2 (line 2): unknown key `param`", + ), + ("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"), + ("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"), + ] { + let (code, out, err) = run_with_stdin(args, input); + assert_eq!(code, 2, "{input:?}: {err}"); + assert!(err.contains(error), "{input:?}: {err}"); + assert!(out.is_empty(), "{input:?}: nothing ran"); + } + // An empty list after positional pairs is no error: there is still something to run. + let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], ""); + assert_eq!(code, 0, "{err}"); + // A step that fails when it runs is named by its index too. + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n"); + assert_eq!(code, 1, "{err}"); + let e = serde_json::from_str::(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default(); + assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}"); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 9346acc29..c8e91a260 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -7,6 +7,23 @@ // // Plain functions only: the Rust side owns the logic. +// ------------------------------------------------------------------ shortcuts + +/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks. +/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because +/// eframe falls back to the key code there; it does not on Dvorak, where it types E. +/// eframe still receives the key; other chords and HTML inputs keep their browser behavior. +export function installCanvasShortcuts(canvas) { + canvas.addEventListener("keydown", (event) => { + if (event.target === canvas && (event.ctrlKey || event.metaKey) + && !event.shiftKey && !event.altKey && !event.isComposing + && (event.key.toLowerCase() === "d" + || (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) { + event.preventDefault(); + } + }, { capture: true }); +} + // ------------------------------------------------------------------ storage const DIR = "effectcraft"; diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 169529e5d..60ff2f8d3 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::startup::Startup; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*; extern "C" { #[wasm_bindgen(js_name = showDeviceLost)] fn show_device_lost(message: &str, kept: bool); + #[wasm_bindgen(js_name = installCanvasShortcuts)] + fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement); } /// Work for the UI thread with access to the app (posted by async tasks: picked files…). @@ -81,8 +84,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -115,6 +122,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -204,6 +216,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; + install_canvas_shortcuts(&canvas); let empty = query_flag("empty"); let home = query_flag("home"); // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and @@ -220,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); + // `./effectcraft-config.json`: what to open on load. + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -245,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -255,10 +273,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { let mut session = session(); session.load_settings(); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); @@ -284,7 +303,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..614d7d4cb --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,73 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). +async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..4759096f3 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,55 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 8aafdac1c..c5c4ada94 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -366,6 +366,21 @@ impl ConfigStore for WebConfig { v.sort(); v } + /// The first name part of the keys under `dir/`. + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/')); + let mut v: Vec = self + .store + .lock() + .list(&pre) + .into_iter() + .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string())) + .filter(|d| !d.is_empty()) + .collect(); + v.sort(); + v.dedup(); + v + } fn dir(&self) -> Option { Some(PathBuf::from("/")) } @@ -533,6 +548,22 @@ mod tests { assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..])); } + /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop. + #[test] + fn config_store_lists_folders() { + let c = WebConfig::new(store()); + for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] { + c.write(name, "x").unwrap(); + } + assert_eq!(c.list("Scripts"), ["Top.jsx"]); + assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]); + assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]); + let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() }; + let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap(); + let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect(); + assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]); + } + #[test] fn config_store_and_file_ops() { let s = store(); diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..3ce90569c --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,52 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +/// The three accepted values parse. +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +/// Other keys do not hide `startup`. +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +/// A file over `CONFIG_MAX` is ignored. +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +/// Which project each value restores or opens. +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 063015236..3728e59e2 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -8,6 +8,8 @@ // - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; // the reload writes pending changes to browser storage first, and a failed write is reported // instead of reloading (#225). +// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering +// the key to the app; other chords and HTML inputs keep their browser behavior (#2495). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; @@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); +// #2495: prevent the browser default without swallowing the app's Duplicate key. +const canvas = new EventTarget(); +const input = new EventTarget(); +host.installCanvasShortcuts(canvas); +let delivered = 0; +canvas.addEventListener("keydown", () => delivered++); +const key = (target, properties) => { + const event = new Event("keydown", { cancelable: true }); + Object.assign(event, { key: "d", ...properties }); + target.dispatchEvent(event); + return event.defaultPrevented; +}; +assert.equal(key(canvas, { ctrlKey: true }), true); +assert.equal(key(canvas, { metaKey: true, key: "D" }), true); +for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) { + assert.equal(key(canvas, properties), false); +} +// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D. +assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true); +// Dvorak: the physical D key types "e", which is not Duplicate. +assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false); +assert.equal(delivered, 9, "all keys still reach the app"); +assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut"); + // #226: one readable and one unreadable file. const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); const picked = host.browsePickFiles(false); diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 64ab5443d..452196224 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -378,9 +378,15 @@ fn main() -> eframe::Result { /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to /// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +/// +/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one +/// frame in flight, so a drag or a scrub shows one frame sooner. The bare +/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three +/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms +/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window. fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; - let mut config = eframe::WgpuConfiguration::default(); + let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { if let Some(b) = backends { setup.instance_descriptor.backends = b; @@ -567,6 +573,19 @@ mod tests { assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } + /// The window's surface keeps one frame in flight, like eframe's own default, not the two + /// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag). + #[test] + fn the_window_surface_is_low_latency() { + for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] { + let surface = super::wgpu_options(backends).surface; + assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}"); + assert_eq!(surface.desired_maximum_frame_latency, Some(1)); + assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync); + } + assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface"); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { diff --git a/contributors/people.toml b/contributors/people.toml index 1012a37bd..8654c7a10 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,11 @@ sync_github_name = false [people.ki2pixel] display_name = "ki2pixel" + +[people.becauseno7] +real_name = "Shuvra Basak" +display_name = "Shuvra Basak" +sync_github_name = false + +[people.ashokram-si] +real_name = "Ashok Ram" diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..67e5aef98 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -7,7 +7,9 @@ //! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the //! Render Queue) are checked against where they would write. Commands that run code or open -//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. +//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File, +//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network +//! access the user withheld. use std::path::{Component, Path, PathBuf}; @@ -35,6 +37,15 @@ const REFUSED: &[&str] = &[ "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", + // A ScriptUI panel's script runs when the panel opens, when an event reaches one of its + // controls, and when one of its `app.scheduleTask` tasks comes due. + "window.scriptPanel", + "window.restoreScriptPanels", + "script.runTasks", + "scriptui.click", + "scriptui.set", + "scriptui.mouse", + "scriptui.close", "file.executeFile", "file.revealInFinder", "layer.openSource", @@ -64,8 +75,26 @@ const READS: &[(&str, &[&str])] = &[ ("effect.plugins.load", &["path", "folder"]), ("mediaBrowser.go", &["path"]), ("mediaBrowser.addFavorite", &["path"]), + // #1243: these read the files they are given but were missing, so a relative path went + // unchecked. + ("essential.templateInfo", &["path"]), + ("file.importProject", &["path"]), + ("file.watchFolder.poll", &["folder"]), + ("footage.relink", &["path"]), + ("layer.newModel", &["path"]), + ("mediaBrowser.fileInfo", &["path"]), + ("mediaBrowser.list", &["path"]), + ("mediaBrowser.import", &["paths"]), + ("mediaBrowser.removeFavorite", &["path"]), + ("face.model.install", &["path"]), + ("roto.model.install", &["path"]), + // Reads the template at `path`; what it writes is in `MIXED_WRITES`. + ("templates.create", &["path"]), ]; +/// Commands that read some parameters (listed in [`READS`]) and write others. +const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])]; + /// Commands that write files named by these parameters. const WRITES: &[(&str, &[&str])] = &[ ("file.save", &["path"]), @@ -84,6 +113,9 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), + ("file.exportTimeline", &["path"]), + // `path` / `output` may also be a file-name template, as for render.preRender. + ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. @@ -138,13 +170,23 @@ impl Confinement { if REFUSED.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } + // Scripts can't grant themselves the access the user withheld + // (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined + // agent: `prefs.set` saves the settings file, so a script run long after this session + // would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.) + if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) { + return Err(Error::BadArgs( + "`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(), + )); + } if id == "engine.batch" { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. - if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } if self.check_command(inner, &inner_params)?.is_some() { @@ -170,7 +212,7 @@ impl Confinement { for p in paths { // A Render Queue output may name a template instead of a file. match p.as_str() { - Some(s) if id == "render.preRender" && !looks_like_path(s) => {} + Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {} Some(s) => { self.check(s, access)?; } @@ -178,9 +220,18 @@ impl Confinement { } } } + // Parameters of a reading command that it writes to. + let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]); + for key in mixed { + if let Some(p) = params.get(*key).filter(|v| !v.is_null()) { + let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?; + self.check(p, Access::Write)?; + } + } // Any other absolute path the command is given. + let named: Vec<&str> = named.iter().chain(mixed).copied().collect(); let mut other = vec![]; - absolute_strings(params, named, &mut other); + absolute_strings(params, &named, &mut other); for s in other { self.check(&s, access)?; } @@ -218,6 +269,22 @@ impl Confinement { } } +/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON +/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as +/// the dotted spelling, and the page on its own replaces every setting on it. +fn is_scripting_pref(key: &str) -> bool { + key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting")) +} + +/// The settings `prefs.set` is given: `key`, and the keys of `values`. +fn prefs_keys(params: &Value) -> Vec<&str> { + let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect(); + if let Some(Value::Object(m)) = params.get("values") { + keys.extend(m.keys().map(String::as_str)); + } + keys +} + fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> { table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) } @@ -274,3 +341,114 @@ fn resolve(p: &Path) -> Option { at = at.parent()?; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Commands whose parameters are named like files but aren't: property paths + /// (`path|prop`), project folders, layers to track or target. + const NOT_FILES: &[&str] = &[ + "camera.createFromSolve", + "edit.label", + "edit.pasteReversedKeyframes", + "effect.editDropdown", + "essential.addProperty", + "essential.canAdd", + "essential.linkProperty", + "essential.unlinkProperty", + "keys.paste", + "keys.set", + "keys.setEase", + "keys.setSpatialTangents", + "paths.nullsFollowPoints", + "paths.pointsFollowNulls", + "paths.tracePath", + "project.move", + "prop.addKey", + "prop.convertExpressionToKeyframes", + "prop.deselect", + "prop.editGradient", + "prop.get", + "prop.pickWhip", + "prop.reset", + "prop.select", + "prop.set", + "prop.setExpression", + "prop.toggleAnimation", + "prop.toggleKey", + "track.new", + "track.property", + "track.setTarget", + ]; + + /// Every engine command with a parameter named like a file is classified, so a new one + /// can't silently skip confinement (#1243): it reads or writes the files it is given, is + /// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs + /// (checked when the queue renders), or is listed in `NOT_FILES`. + #[test] + fn every_command_with_file_parameters_is_classified() { + const FILE_KEYS: &[&str] = + &["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"]; + let mut missing = vec![]; + for spec in effectcraft_engine::command_specs() { + let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue }; + let id = spec.id; + let classified = keys_of(READS, id).is_some() + || keys_of(WRITES, id).is_some() + || REFUSED.contains(&id) + || IMPLICIT_WRITES.contains(&id) + || id.starts_with("renderQueue.") + || id == "render.addOutputModule" + || NOT_FILES.contains(&id); + if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified { + missing.push(format!("{id} {}", spec.params)); + } + } + assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n")); + // And every listed command exists. + for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) { + assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command"); + } + } + + #[test] + fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + let up = format!("{}/../elsewhere/t.ectemplate", read.display()); + for (id, key) in [ + ("essential.templateInfo", "path"), + ("file.importProject", "path"), + ("footage.relink", "path"), + ("layer.newModel", "path"), + ("mediaBrowser.fileInfo", "path"), + ("mediaBrowser.list", "path"), + ("face.model.install", "path"), + ("templates.create", "path"), + ] { + for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] { + assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}"); + } + assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root"); + } + // Batched too. + let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]}); + assert!(c.check_command("engine.batch", &steps).is_err()); + // Writes: into a write root only. + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err()); + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project}); + assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath"); + assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok()); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name"); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err()); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..560a527c7 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() { call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}})); assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B"); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); let (c, err) = call(&mut s, "render_frame", json!({})); assert!(!err, "{c:?}"); @@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() { } } +/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with +/// only `id` and `label`, effects with only `id`, `name` and `category`. +#[test] +fn compact_listings_keep_only_ids_and_names() { + let mut s = server(); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); + let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true})); + assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}"); + // Each compact listing is the full one with the same arguments, entry for entry; `compact` + // wins over `schemas`. + let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| { + let full = call_json(s, tool, args.clone()); + let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect(); + let mut compact = args.clone(); + compact["compact"] = json!(true); + assert_eq!(call_json(s, tool, compact), want, "{tool} {args}"); + }; + for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] { + check(&mut s, "list_commands", args.clone(), &["id", "label"]); + check(&mut s, "command_list", args, &["id", "label"]); + } + check(&mut s, "list_effects", json!({}), &["id", "name", "category"]); + check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]); + // `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it. + let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string(); + for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] { + let full = text(&mut s, tool, json!({})); + assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}"); + let compact = text(&mut s, tool, json!({"compact": true})).len(); + assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len()); + } +} + #[test] fn high_level_tools_add_effect_and_report_state() { let mut s = server(); @@ -1380,3 +1416,60 @@ fn automation_roots_confine_file_access() { assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); } + +/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file +/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens, +/// when an event reaches one of its controls, or when one of its tasks comes due — and +/// `prefs.set` could grant every script run afterwards the file and network access the user +/// withheld, in the settings file, long after the session. All of that is refused too. +#[test] +fn automation_roots_refuse_running_script_code() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let mut s = server(); + s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap()); + let refused = |s: &mut McpServer, name: &str, args: Value| { + let (c, err) = call(s, name, args.clone()); + assert!(err, "{name} {args} should be refused: {c:?}"); + c[0]["text"].as_str().unwrap_or_default().to_string() + }; + // Every way a script's code starts. + for (command, params) in [ + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ("script.runTasks", json!({})), + ("scriptui.click", json!({"window": 1, "widget": 2})), + ("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})), + ("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})), + ("scriptui.close", json!({"window": 1})), + ] { + let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params})); + assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}"); + } + // Including as a batch step. + let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]})); + assert!(msg.contains("not available while file access is confined"), "{msg}"); + // What scripts may do stays as the user left it, by either spelling of the setting. + for params in [ + json!({"key": "scripting.allowScriptsWriteFiles", "value": true}), + json!({"key": "scripting.allow_scripts_write_files", "value": true}), + json!({"values": {"scripting.warnExecutingFiles": false}}), + json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}), + // `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too… + json!({"key": "scripting/allowScriptsWriteFiles", "value": true}), + // …and the page on its own replaces every setting on it. + json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}), + json!({"values": {"scripting": {"warnExecutingFiles": false}}}), + ] { + let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params})); + assert!(msg.contains("what a script may do"), "{msg}"); + } + let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}})); + assert_eq!(got, json!(false), "{got}"); + // Other settings, and looking at the script windows, still work. + call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}})); + call_json(&mut s, "execute_command", json!({"command": "scriptui.list"})); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a83e0d1ec..1d6074247 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools +/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`. fn list_commands(b: &mut Backend, a: &Value) -> Result { - let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false); + let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true)); let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; - // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut c| { + if compact { + return json!({"id": c["id"], "label": c["label"]}); + } + // Drop empty fields. if let Some(o) = c.as_object_mut() { o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); } @@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("command.describe", json!({"command": id}))?) } +/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`. fn list_effects(b: &mut Backend, a: &Value) -> Result { - json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) + let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?; + if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) { + r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect(); + } + json_reply(r) } fn list_fonts(b: &mut Backend, a: &Value) -> Result { @@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result { static TOOLS: &[ToolDef] = &[ ToolDef { name: "list_commands", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) @@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "list_effects", - description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.", + description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.", bridge_only: false, - schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]), + schema: || { + schema( + json!({ + "filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}, + "compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."} + }), + &[], + ) + }, run: list_effects, }, ToolDef { @@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "command_list", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index c922c95eb..dcc343693 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -48,8 +48,10 @@ reconstruction, so prediction never drifts. reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra, coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts. - **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame - towards the average target (key frames get a lower quantizer and a larger share; the first - key frame is searched in a few passes). + towards the average target (key frames get a lower quantizer and a larger share of the budget of + their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a + few passes, a later key frame that misses its budget by more than 19 % is coded again; the + deviation from the budget is paid back over 16 frames). ## Limits diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index 9bbd56823..d8bc1f825 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,7 +3,7 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane}; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra}; use crate::tables::{ AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, @@ -174,6 +174,8 @@ pub(crate) struct Decider<'a> { ac_q: f32, dc_qi: i64, ac_qi: i64, + /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`. + dead_zone: [[f32; 2]; 2], lambda: f64, lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). @@ -183,6 +185,9 @@ pub(crate) struct Decider<'a> { } const MODE_BITS_INTRA: f64 = 3.0; +/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks. +const INTER_RND: f32 = 0.30; +const INTRA_RND: f32 = 0.38; impl<'a> Decider<'a> { pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self { @@ -209,10 +214,11 @@ impl<'a> Decider<'a> { ac_q: ac_qi as f32, dc_qi, ac_qi, + dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))), lambda, lambda_sad: lambda.sqrt(), half: match refr { - Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)), + Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd), None => Default::default(), }, scratch: None, @@ -577,8 +583,9 @@ impl<'a> Decider<'a> { let (dy, dx) = (m[0] >> 3, m[1] >> 3); dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1 }; + let lg = log2_table(); let mv_bits = |m: [i32; 2]| -> f64 { - let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() }; + let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) }; let cost = @@ -863,12 +870,18 @@ impl<'a> Decider<'a> { } forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half - let rnd = if intra { 0.38f32 } else { 0.30f32 }; + let rnd = if intra { INTRA_RND } else { INTER_RND }; + let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]]; levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { - let q = if k == 0 { self.dc_q } else { self.ac_q }; - let a = (cf.abs() / q + rnd).floor() as i32; + let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) }; + // dead zone: below the exact threshold the level is zero, no division needed + if cf.abs() < thr { + continue; + } + // the operand is >= 0, so truncation equals floor + let a = (cf.abs() / q + rnd) as i32; if a != 0 { *l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) }; nonzero = true; @@ -913,6 +926,33 @@ impl<'a> Decider<'a> { } } +/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is +/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary. +fn zero_threshold(q: f32, rnd: f32) -> f32 { + let (mut lo, mut hi) = (0u32, f32::MAX.to_bits()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if (f32::from_bits(mid) / q + rnd) as i32 >= 1 { + hi = mid; + } else { + lo = mid + 1; + } + } + f32::from_bits(lo) +} + +const LOG2_TABLE_LEN: usize = 1025; + +/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form. +fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] { + static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2())) +} + +fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 { + if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() } +} + /// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place. fn hadamard(v: &mut [i32]) { let n = v.len(); @@ -941,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 { if eob == 0 { return 1.0; } - let mut bits = 3.0 + (eob as f64).log2(); + let lg = log2_table(); + let mut bits = 3.0 + log2_of(lg, eob); for &pos in &scan[..eob] { let a = levels[pos as usize].unsigned_abs(); bits += match a { 0 => 0.7, 1 => 2.5, - _ => 3.5 + 1.6 * (a as f64).log2(), + _ => 3.5 + 1.6 * log2_of(lg, a as usize), }; } bits @@ -961,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] { _ => &DEFAULT_SCAN_32X32, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::{AC_QLOOKUP, DC_QLOOKUP}; + + /// The quantiser as it was before the dead-zone threshold and the truncation. + fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 { + (cf.abs() / q + rnd).floor() as i32 + } + + fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 { + if cf.abs() < thr { + return 0; + } + (cf.abs() / q + rnd) as i32 + } + + #[test] + fn dead_zone_threshold_is_exact() { + let mut state = 0x1234_5678_9abc_def0u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) { + for &qi in table.iter() { + let q = qi as f32; + for rnd in [INTER_RND, INTRA_RND] { + let thr = zero_threshold(q, rnd); + // the boundary itself and its neighbours + let b = thr.to_bits(); + for bits in b.saturating_sub(3)..=b + 3 { + let cf = f32::from_bits(bits); + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}"); + assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr)); + } + for _ in 0..200 { + let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff); + if cf.is_nan() { + continue; + } + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + let cf = (next() % 100_000) as f32 / 100.0; + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + } + } + } + } + } + + #[test] + fn log2_table_matches_direct_log2() { + let t = log2_table(); + for v in 1..3000usize { + assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); + } + } +} diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index 3fcf446bb..b86db0fa2 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -24,6 +24,7 @@ use bitw::{BitWriter, obu}; use decide::{Decider, FrameGeom, RefFrame}; use loopfilter::{LfInfo, loop_filter}; use pred::Plane; +use tables::AC_QLOOKUP; use writer::TileWriter; /// Rate control. @@ -136,10 +137,118 @@ struct RateState { frame_bits: f64, /// Quantizer index for inter frames (continuous). q: f64, + /// Key frame quantizer index minus the inter frame one. + key_off: f64, + /// Quantizer index steps per halving of the frame size, measured from consecutive inter + /// frames (the size/quantizer curve is much steeper than the default on noisy content). + slope: f64, + /// Quantizer index and size (in bits) of the previous inter frame. + prev_inter: Option<(f64, f64)>, /// Accumulated (spent - budget) bits. debt: f64, } +/// Quantizer index steps per halving of the frame size before any measurement. +const RC_SLOPE: f64 = 28.0; +/// Fraction of the quantizer step the size error asks for that is applied per frame. +const RC_GAIN: f64 = 0.8; +/// Frames over which an accumulated deviation from the budget is paid back. +const RC_PAYBACK: f64 = 16.0; + +impl RateState { + /// The nominal bit budget and the starting quantizer index of a key or inter frame. A key + /// frame gets a larger share, and the inter frames of the group get the rest, so that a whole + /// group of `keyint` frames adds up to `keyint * frame_bits`. + fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) { + let keyint = keyint.max(1) as f64; + let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5); + if key { + (self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0)) + } else if keyint > 1.0 { + (self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q) + } else { + (self.frame_bits, self.q) + } + } + + /// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`. + fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) { + let err = (bits / target).log2().clamp(-1.5, 1.5); + if first && key { + // four passes ran: the key frame quantizer is known, the inter one follows from the offset + self.q = (q_used - self.key_off).clamp(1.0, 255.0); + } else if key { + self.key_off = q_used - self.q + RC_GAIN * self.slope * err; + } else { + if let Some((pq, pbits)) = self.prev_inter { + let (dq, dr) = (q_used - pq, (pbits / bits).log2()); + if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 { + self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0); + } + } + self.prev_inter = Some((q_used, bits)); + self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0); + } + } +} + +/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be +/// before it is accepted. +const RC_FIRST_PASSES: usize = 4; +const RC_FIRST_TOLERANCE: f64 = 0.04; +/// Quantizer index steps per halving of the size, for the first correction of the first frame +/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it +/// is, and from there on the passes are placed by `next_probe_q`). +const RC_FIRST_SLOPE: f64 = 30.0; +/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves +/// (19 %) off the target. +const RC_KEY_PASSES: usize = 3; +const RC_KEY_TOLERANCE: f64 = 0.25; +/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame. +const RC_PROBE_SLOPE: f64 = 1.0; +/// Largest change of the quantizer step (octaves) between two passes on one frame. +const RC_PROBE_MAX_STEP: f64 = 2.0; + +/// The quantizer index whose step size is closest to `2^x`. +fn q_for_step(bit_depth: u8, x: f64) -> u8 { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let mut best = 1; + for q in 1..=255usize { + if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() { + best = q; + } + } + best as u8 +} + +/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))` +/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the +/// step only below about 60) in a near log-log line: false position between the closest pair of +/// passes that enclose the target, otherwise a step from the latest one with the slope of the +/// last two. `None` when no untried quantizer lies between the enclosing passes. +fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let x = |q: u8| (table[q as usize] as f64).log2(); + let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0); + let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0); + if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) { + if q_hi - q_lo < 2 { + return None; + } + let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi); + return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1)); + } + let &(q, err) = tried.last()?; + let mut slope = RC_PROBE_SLOPE; + if let [.., (q0, e0), (q1, e1)] = tried + && (x(*q1) - x(*q0)).abs() > 0.05 + && (e0 - e1) / (x(*q1) - x(*q0)) > 0.0 + { + slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0); + } + Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP))) +} + /// AV1 encoder. Frames come out in input order (no hidden/reordered frames). pub struct Encoder { cfg: EncoderConfig, @@ -185,7 +294,7 @@ impl Encoder { // initial guess from the bits per sample let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64); let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0); - Some(RateState { frame_bits, q, debt: 0.0 }) + Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 }) } }; let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc }; @@ -471,38 +580,52 @@ impl Encoder { fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec, RefFrame) { let first = self.frame_num == 0; - let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q); - // key frames get a lower quantizer and a larger share of the budget - let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75; - let budget = if key { frame_bits * key_boost } else { frame_bits }; - // pay back a fraction of the accumulated deviation - let target = (budget - debt * 0.25).max(budget * 0.25); - let mut q = if key { q_inter - 24.0 } else { q_inter }; - let mut best: Option<(Vec, RefFrame, f64)> = None; - let passes = if first { 4 } else { 1 }; - for pass in 0..passes { + let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key); + // pay back the accumulated deviation over the next RC_PAYBACK frames + let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0); + let mut q = q_start; + let mut best: Option<(Vec, RefFrame, f64, f64)> = None; + // The first frame is coded up to four times to find its quantizer (it fixes the + // quantizers of everything after it), a later key frame up to three times (it is a large + // part of the budget of its group). A frame is coded again only while its size is far from + // the target and an untried quantizer is left to try; other frames are coded once. + let (max_passes, tolerance) = if first { + (RC_FIRST_PASSES, RC_FIRST_TOLERANCE) + } else if key { + (RC_KEY_PASSES, RC_KEY_TOLERANCE) + } else { + (1, 0.0) + }; + let mut tried: Vec<(u8, f64)> = Vec::new(); + for _ in 0..max_passes { let qi = q.round().clamp(1.0, 255.0) as u8; + if tried.iter().any(|t| t.0 == qi) { + break; + } let (obu, rf) = self.encode_frame(src, key, qi); let bits = obu.len() as f64 * 8.0; let err = (bits / target).log2(); - let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()); - if better { - best = Some((obu, rf, bits)); + tried.push((qi, err)); + if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) { + best = Some((obu, rf, bits, qi as f64)); } - if pass + 1 < passes { - q = (q + 30.0 * err).clamp(1.0, 255.0); + if err.abs() <= tolerance { + break; + } + let next = if first && tried.len() == 1 { + Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0)) + } else { + next_probe_q(self.cfg.bit_depth, &tried).map(f64::from) + }; + match next { + Some(next) => q = next, + None => break, } } // At least one pass ran, so `best` is set. - let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; + let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; rc.debt += bits - budget; - // adapt the inter quantizer: about 30 index steps per doubling of the rate - let err = (bits / target).log2().clamp(-1.0, 1.0); - if first && key { - rc.q = (q + 24.0).clamp(1.0, 255.0); - } else { - rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0); - } + rc.update(key, first, q_used, bits, target); (obu, rf) } diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index 7b354af1f..b899202f4 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -297,7 +297,8 @@ pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) /// The visible `w x h` area of a reference plane interpolated at the sub-sample phase /// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for -/// blocks wider and taller than 4. Used for motion search. +/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses. +#[cfg(test)] pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec { let kh = &SUBPEL_FILTERS[EIGHTTAP][fx]; let kv = &SUBPEL_FILTERS[EIGHTTAP][fy]; @@ -327,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz out } +/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the +/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to +/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )), +/// so the result equals three `subpel_plane` calls bit for bit. +pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] { + let k = &SUBPEL_FILTERS[EIGHTTAP][8]; + let max = (1i32 << bit_depth) - 1; + let (lx, ly) = (w as i32 - 1, h as i32 - 1); + // horizontal pass at phase 8: Round2( sum, 3 ) + let mut hp = vec![0i32; w * h]; + for y in 0..h { + let row = &refp.row(y)[..w]; + let out = &mut hp[y * w..y * w + w]; + let tap = |x: i32| -> i32 { + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32; + } + round2(s, 3) + }; + if w >= 8 { + for x in 0..3 { + out[x] = tap(x as i32); + } + for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) { + let seg = &row[x - 3..x + 5]; + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * seg[t] as i32; + } + *o = round2(s, 3); + } + for x in w - 4..w { + out[x] = tap(x as i32); + } + } else { + for (x, o) in out.iter_mut().enumerate() { + *o = tap(x as i32); + } + } + } + let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect(); + let mut p08 = vec![0u16; w * h]; + let mut p88 = vec![0u16; w * h]; + for y in 0..h { + let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize); + let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]); + for x in 0..w { + let (mut a, mut b) = (0i32, 0i32); + for t in 0..8 { + a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32; + b += k[t] as i32 * hp[rows[t] * w + x]; + } + o08[x] = round2(a, 7).clamp(0, max) as u16; + o88[x] = round2(b, 11).clamp(0, max) as u16; + } + } + [p80, p08, p88] +} + #[cfg(test)] mod tests { use super::*; @@ -361,6 +422,20 @@ mod tests { } } + #[test] + fn half_planes_match_subpel_plane() { + for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] { + let mut p = Plane::new(64, 64); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 1024) as u16; + } + let hp = half_planes(&p, w, h, 10); + for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() { + assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})"); + } + } + } + #[test] fn phase_zero_filters_are_impulses() { for f in SUBPEL_FILTERS.iter() { diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 048bfc0db..9d81ead0b 100644 --- a/crates/av1enc/src/transform.rs +++ b/crates/av1enc/src/transform.rs @@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 { cos128(angle - 64) } +#[inline(always)] fn brev(num_bits: u32, x: usize) -> usize { let mut t = 0; for i in 0..num_bits { @@ -63,8 +64,11 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) { t[b] = (x - y).clamp(lo, hi); } -/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place. -fn inverse_dct(t: &mut [i32], n: u32, r: u32) { +/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the +/// bit-reversal permutations and loop bounds fold at compile time. +#[inline(always)] +fn inverse_dct_n(t: &mut [i32], r: u32) { + let n = N; let len = 1usize << n; let mut copy = [0i32; 32]; copy[..len].copy_from_slice(&t[..len]); @@ -76,6 +80,7 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } /// Rotate the high-frequency branches before the four-point core. +#[inline(always)] fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { @@ -112,6 +117,7 @@ fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { } /// Evaluate the four-point core and merge progressively larger branches. +#[inline(always)] fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); @@ -190,6 +196,7 @@ const SINPI_2_9: i64 = 2482; const SINPI_3_9: i64 = 3344; const SINPI_4_9: i64 = 3803; +#[inline(always)] fn inverse_adst4(t: &mut [i32]) { let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64); let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3]; @@ -207,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) { } } -fn adst_in_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_in_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -217,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) { } } -fn adst_out_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_out_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -231,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) { } } +#[inline(always)] fn inverse_adst8(t: &mut [i32], r: u32) { - adst_in_permute(t, 3); + adst_in_permute::<3>(t); for i in 0..4 { bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true); } @@ -250,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) { for i in 0..2 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 3); + adst_out_permute::<3>(t); } +#[inline(always)] fn inverse_adst16(t: &mut [i32], r: u32) { - adst_in_permute(t, 4); + adst_in_permute::<4>(t); for i in 0..8 { bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true); } @@ -283,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) { for i in 0..4 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 4); + adst_out_permute::<4>(t); } /// 1-D transform kind. @@ -308,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) { } } +#[inline(always)] +fn inverse_dct(t: &mut [i32], n: u32, r: u32) { + match n { + 2 => inverse_dct_n::<2>(t, r), + 3 => inverse_dct_n::<3>(t, r), + 4 => inverse_dct_n::<4>(t, r), + _ => inverse_dct_n::<5>(t, r), + } +} + +#[inline(always)] fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { match kind { Kind::Dct => inverse_dct(t, n, r), @@ -322,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { /// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised /// coefficients (row stride `n`) into `residual` (row stride `n`). pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { - let log2 = TX_WIDTH_LOG2[tx_sz] as u32; + match TX_WIDTH_LOG2[tx_sz] { + 2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth), + 3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth), + 4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth), + _ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth), + } +} + +/// `inverse_2d` for a block of width `1 << LOG2`. +fn inverse_2d_n(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { + let log2 = LOG2; let n = 1usize << log2; let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32; let row_clamp = bit_depth + 8; @@ -429,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] { /// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain /// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser). pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { + match n { + 4 => forward_2d_n::<4, 16>(residual, out, tx_type), + 8 => forward_2d_n::<8, 64>(residual, out, tx_type), + 16 => forward_2d_n::<16, 256>(residual, out, tx_type), + _ => forward_2d_dyn(residual, out, n, tx_type), + } +} + +/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing). +#[inline(always)] +fn forward_2d_n(residual: &[i32], out: &mut [f32], tx_type: usize) { + let n = N; + let (ck, rk) = kinds(tx_type); + let bc = &basis(ck, n)[..NN]; + let brt = &basis_t(rk, n)[..NN]; + let residual = &residual[..NN]; + let out = &mut out[..NN]; + let mut tmp = [0f32; NN]; + for i in 0..n { + let t = &mut tmp[i * n..i * n + n]; + for j in 0..n { + let r = residual[i * n + j]; + if r == 0 { + continue; + } + let r = r as f32; + for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) { + *o += r * b; + } + } + } + out.iter_mut().for_each(|v| *v = 0.0); + for k in 0..n { + let o = &mut out[k * n..k * n + n]; + for i in 0..n { + let b = 8.0 * bc[k * n + i]; + for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) { + *v += b * t; + } + } + } +} + +/// 32x32 (and any other size): a const-generic 32 version measured slower. +fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { let (ck, rk) = kinds(tx_type); let bc = basis(ck, n); let brt = basis_t(rk, n); diff --git a/crates/av1enc/tests/it/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs index 282880e08..edc98c370 100644 --- a/crates/av1enc/tests/it/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() { assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s"); } +/// Over several key frame intervals the average rate stays within 5 % of the target, for a +/// range of targets (the key frame budget is taken out of the inter frames of its group). +#[test] +fn bitrate_mode_is_accurate_over_many_groups() { + let n = 180; + for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] { + let pics: Vec = (0..n).map(|t| synth(192, 128, bd, t)).collect(); + let mut c = cfg(192, 128, bd as u8, 0, keyint); + c.rate = RateControl::Bitrate { kbps }; + let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap(); + let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum(); + let got = bits as f64 / (n as f64 / 30.0) / 1000.0; + eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}"); + assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})"); + } +} + #[test] fn headers() { let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2a0dfc50e..71eb43296 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { return (0.0, 0.0, l); } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let denom = if l > 0.5 { 2.0 - max - min } else { max + min }; + // Over-range (HDR) or negative inputs can make the denominator zero or negative. + let s = if denom > 0.0 { d / denom } else { 1.0 }; let h = if max == r { (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { @@ -262,6 +264,20 @@ impl Label { mod tests { use super::*; + #[test] + fn hsl_round_trip_is_finite_for_out_of_range_colours() { + for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] { + let (h, s, l) = rgb_to_hsl(r, g, b); + assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}"); + let (r2, g2, b2) = hsl_to_rgb(h, s, l); + assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}"); + } + // A 32 bpc colour whose denominator stays positive still round-trips exactly. + let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5); + let (r, g, b) = hsl_to_rgb(h, s, l); + assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}"); + } + #[test] fn hsv_to_rgb_tiny_negative_hue_is_red() { let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); diff --git a/crates/effects/src/analysis_cache.rs b/crates/effects/src/analysis_cache.rs new file mode 100644 index 000000000..98764d205 --- /dev/null +++ b/crates/effects/src/analysis_cache.rs @@ -0,0 +1,97 @@ +//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated +//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter +//! strings can be cloned, edited in place, or allocated again at an old address. + +use std::sync::{Arc, Mutex, OnceLock}; + +struct Entry { + json: Box, + key: u64, + value: Arc, +} + +pub(crate) struct AnalysisCache { + entries: OnceLock>>>, +} + +pub(crate) fn fnv(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +impl AnalysisCache { + pub(crate) const fn new() -> Self { + Self { entries: OnceLock::new() } + } + + pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option) -> Option<(u64, Arc)> { + let entries = self.entries.get_or_init(Default::default); + { + let m = entries.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + } + let value = Arc::new(parse(json)?); + let key = fnv(json.as_bytes()); + let mut m = entries.lock().unwrap_or_else(|e| e.into_inner()); + // Another render may have filled the entry while this one parsed it. + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + // Preserve the existing nine-entry bound and clear-on-overflow policy. + if m.len() > 8 { + m.clear(); + } + m.push(Entry { json: json.into(), key, value: value.clone() }); + Some((key, value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn reuses_content_and_invalidates_same_allocation_edits() { + let cache = AnalysisCache::new(); + let calls = Cell::new(0); + let parse = |s: &str| { + calls.set(calls.get() + 1); + Some(s.to_owned()) + }; + let mut json = String::from("analysis-1"); + let (key, a) = cache.get(&json, parse).unwrap(); + let copy = json.clone(); + assert_ne!(json.as_ptr(), copy.as_ptr()); + let (same_key, same) = cache.get(©, parse).unwrap(); + assert_eq!(key, fnv(json.as_bytes())); + assert_eq!(key, same_key); + assert!(Arc::ptr_eq(&a, &same)); + assert_eq!(calls.get(), 1); + let ptr = json.as_ptr(); + json.replace_range(9..10, "2"); + assert_eq!(ptr, json.as_ptr()); + let (changed_key, changed) = cache.get(&json, parse).unwrap(); + assert_ne!(key, changed_key); + assert_eq!(&*changed, "analysis-2"); + assert_eq!(&*a, "analysis-1"); + assert_eq!(calls.get(), 2); + assert!(cache.get("invalid", |_| None).is_none()); + assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed)); + } + + #[test] + fn source_text_retention_is_bounded() { + let cache = AnalysisCache::new(); + for i in 0..30 { + cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap(); + assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9); + } + } +} diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs index ba7878abf..c58df3593 100644 --- a/crates/effects/src/camera_tracker.rs +++ b/crates/effects/src/camera_tracker.rs @@ -16,8 +16,9 @@ //! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create //! commands live in the engine and the UI. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; + +use crate::analysis_cache::AnalysisCache; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; @@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String { format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted) } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - -type Cache = Mutex>>; - -fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> { - let key = fnv(json.as_bytes()); - let c = c.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(a.clone()); - } - let a = Arc::new(parse(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some(a) -} - /// The stored feature tracks of an instance (`None` when not analysed). pub fn tracks(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, TRACKS); if j.is_empty() { return None; } - cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// The stored camera solve of an instance (`None` when not solved). pub fn solve(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, SOLVE); if j.is_empty() { return None; } - cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the @@ -338,6 +313,27 @@ mod tests { assert!(!st.lens_distortion && !solve_key(&st).contains("lens")); } + #[test] + fn tracks_and_solve_follow_analysis_replacements() { + let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() }; + let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() }; + let mut ps = params_with(&sol, false); + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + t.start = 2.0; + sol.start = 2.0; + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + ps.values.insert(SOLVE.into(), Value::Str(sol.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + assert_eq!(*tracks(&ps.clone()).unwrap(), t); + ps.values.insert(TRACKS.into(), Value::Str("invalid".into())); + ps.values.insert(SOLVE.into(), Value::Str(String::new())); + assert!(tracks(&ps).is_none()); + assert!(solve(&ps).is_none()); + } + #[test] fn undistort_footage_remaps_through_the_solved_lens() { use effectcraft_track::camtrack::Distortion; diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 08241107c..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,6 +700,7 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 4fc84440e..e69c2a6aa 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() { let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); } + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env); + assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..db413544b 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -19,7 +19,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::generate::value_noise; +use crate::generate::value_noise_linear_z; use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi { // ---------------------------------------------------------------- Turbulent Displace +/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of +/// plain value noise a few degrees of Evolution barely moved the pattern (#943). fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 { let n = octaves.ceil().max(1.0) as usize; let frac = (octaves - octaves.floor()) as f32; let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32); for o in 0..n { let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 }; - sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; + sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; norm += amp * w; amp *= falloff; f *= 2.0; @@ -1115,6 +1117,45 @@ mod tests { assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked"); } + /// #943: every non-equivalent Evolution value renders a visibly different frame. The eased + /// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other. + #[test] + fn turbulent_displace_evolution_changes_the_output() { + // A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and + // the difference is purely how far the pattern moved. + let mut ramp = Image::new(64, 64); + for y in 0..64u32 { + for x in 0..64u32 { + ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]); + } + } + let render = |evo: f64, extra: &[(&str, Value)]| { + let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))]; + run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img + }; + let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5]; + let frames: Vec = evolutions.iter().map(|&e| render(e, &[])).collect(); + for (i, a) in frames.iter().enumerate() { + for (j, b) in frames.iter().enumerate().skip(i + 1) { + let d = max_diff(a, b); + assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]); + } + } + // Constant rate: 20 degrees next to a whole revolution (from either side) is a visible + // step, not the rounding-error size the eased axis gave (under 0.001). + assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02); + assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02); + // 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with + // Cycle (in Revolutions) = 1, at any starting angle. + assert!(max_diff(&frames[0], &frames[4]) > 0.01); + let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))]; + assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4); + assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4); + assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01); + // Random Seed still gives a different pattern at the same Evolution. + assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01); + } + #[test] fn magnify_blending_none_and_scaling() { let img = test_img(32, 32); diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 637cc428e..ae0d61861 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 { t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } -/// 3D value noise in 0..1 (z = evolution). +/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z +/// eases in and out of every integer. pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, true) +} + +/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic +/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible +/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate. +pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, false) +} + +fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 { let (x0, y0, z0) = (x.floor(), y.floor(), z.floor()); - let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0)); + let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 }); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed); @@ -414,6 +426,27 @@ mod generator_tests { Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0]) } + /// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the + /// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it). + #[test] + fn linear_z_value_noise_moves_at_a_constant_rate() { + for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] { + let a = value_noise_linear_z(x, y, 0.0, seed); + let b = value_noise_linear_z(x, y, 1.0, seed); + for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] { + let want = a + (b - a) * z; + let got = value_noise_linear_z(x, y, z, seed); + assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}"); + } + // Same lattice values at whole z, and x / y are untouched. + assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed)); + } + // The eased version still has the old shape: at z = 0.25 it lags the linear one. + let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5)); + let eased = value_noise(0.3, 0.7, 0.25, 5); + assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3); + } + #[test] fn gradient_ramp_stays_inside_the_layer_alpha() { // #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 204e651ec..40762becc 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod analysis_cache; pub mod audio_fx; mod blur2; mod blur3; @@ -311,14 +312,15 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } - /// Grow by `pad` transparent pixels on every side. + /// Grow by `pad` transparent pixels on every side (as clamped by [`Image::effective_pad`]). pub fn pad(&mut self, pad: u32) { - if pad == 0 { + let eff = self.img.effective_pad(pad); + if eff == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; } } @@ -445,6 +447,18 @@ pub trait EffectHost: Sync { fn params_at(&self, _layer_time: f64) -> Option { None } + /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in + /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer + /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373). + /// `None` when unavailable. + fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { + None + } + /// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no + /// expression), so sampling it over time can be skipped. `false` when unknown. + fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool { + false + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { @@ -1300,6 +1314,13 @@ pub fn is_time_dependent(id: &str) -> bool { id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } +/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample +/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an +/// expression returned then. +pub fn reads_param_history(id: &str) -> bool { + matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury") +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) @@ -1331,6 +1352,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b5976b761..35d0960e0 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay *g != before } +/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now: +/// `id` itself when the spec still has it, the new id of a renamed parameter +/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a +/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches. +pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> { + if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) { + return Some(ps.id); + } + if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) { + return Some(new); + } + if id.contains('/') { + return None; + } + spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id) +} + /// Bring an effect instance saved before project schema 2 into effect space (#227). /// /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on @@ -559,6 +576,21 @@ mod tests { assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + /// A saved parameter id maps to the parameter `upgrade_instance` moves it to. + #[test] + fn current_param_id_follows_renames_and_twirl_down_moves() { + let stab = find(crate::warp_stab::ID).unwrap(); + assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness")); + assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness")); + let ctc = find("ec.color.changetocolor").unwrap(); + assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue")); + // Radio Waves' old Velocity is the growth speed, not the new Velocity. + let waves = find("ec.generate.radiowaves").unwrap(); + assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion")); + assert_eq!(current_param_id(stab, "noSuchParam"), None); + assert_eq!(current_param_id(stab, "other/smoothness"), None); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index d39fd6528..9d21e77a7 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); - let mut cands: Vec<(f32, usize, usize)> = Vec::new(); - for y in (0..=h - size).step_by(step) { - for x in (0..=w - size).step_by(step) { - // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); - for yy in y..y + size { - for xx in x..x + size { - let v = low.data[yy * w + xx]; - s += v; - sq += v * v; - n += 1.0; - opaque &= img.data[yy * w + xx][3] > 0.5; + // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the + // result does not depend on the collection order. + let rows: Vec = (0..=h - size).step_by(step).collect(); + let mut cands: Vec<(f32, usize, usize)> = rows + .par_iter() + .flat_map_iter(|&y| { + let data = low.data.as_slice(); + let alpha = img.data.as_slice(); + (0..=w - size).step_by(step).filter_map(move |x| { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= alpha[yy * w + xx][3] > 0.5; + } } - } - if opaque { - cands.push((sq / n - (s / n).powi(2), x, y)); - } - } - } + opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + }) + }) + .collect(); cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() } @@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u /// The grain level (0.5..2) measured in the sample boxes of `img`. fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { - let mut m = vec![false; img.data.len()]; - let w = img.width as usize; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let st = crate::noise2::grain_stats_boxes(img, boxes); let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 4c22f6d8f..48d840416 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -75,6 +75,102 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each +/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like +/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match +/// the masked version. +pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats { + if boxes.is_empty() { + return grain_stats_masked(img, None); + } + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the + // residual exact inside a box; one more column covers the right neighbour used by the lag-1 + // correlation. + const HALO: usize = 4; + let mut mask = vec![false; w * h]; + for &(x, y, s) in boxes { + for yy in y..y.saturating_add(s).min(h) { + for xx in x..x.saturating_add(s).min(w) { + mask[yy * w + xx] = true; + } + } + } + let mut counted = vec![false; w * h]; + let mut n = [0.0f64; 3]; + let mut sum = [0.0f64; 3]; + let mut sq = [0.0f64; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + let mut crop: Vec<([f32; 3], f32)> = Vec::new(); + let mut planes: Vec = Vec::new(); + for &(bx, by, bs) in boxes { + let x0 = bx.saturating_sub(HALO); + let y0 = by.saturating_sub(HALO); + let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w); + let y1 = by.saturating_add(bs).saturating_add(HALO).min(h); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (cw, ch) = (x1 - x0, y1 - y0); + crop.clear(); + crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x])))); + planes.clear(); + for k in 0..3 { + let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() }; + planes.push(gauss_plane(&pl, 1.5, 1.5)); + } + for ly in 0..bs { + let y = by + ly; + if y >= h { + break; + } + for lx in 0..bs { + let x = bx + lx; + if x >= w { + break; + } + let g = y * w + x; + if !mask[g] || counted[g] { + continue; + } + counted[g] = true; + let l = (y - y0) * cw + (x - x0); + let (_, a) = crop[l]; + if a <= 0.5 { + continue; + } + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + sum[k] += hp as f64; + sq[k] += (hp as f64) * (hp as f64); + n[k] += 1.0; + } + if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 { + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + let hn = crop[l + 1].0[k] - planes[k].data[l + 1]; + rho_num += (hp * hn) as f64; + rho_den += (hp * hp) as f64; + } + } + } + } + } + let mut std = [0.0f32; 3]; + for k in 0..3 { + if n[k] > 1.0 { + let mean = sum[k] / n[k]; + std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32; + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + /// Match Grain's Viewing Mode options. const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; @@ -86,34 +182,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); let src_boxes = sampling_boxes(ctx, &source, b.scale); - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); if view == 1 || view == 2 { // Noise Samples (on the source) / Compensation Samples (on this layer). - let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) }; draw_boxes(&mut img, &boxes, box_col); b.img = img; return b; } - let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { - if boxes.is_empty() { - return None; - } - let w = img.width as usize; - let mut m = vec![false; img.data.len()]; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - Some(m) - }; - let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); + let src_stats = grain_stats_boxes(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; - let orig = b.img.clone(); + // The own-layer boxes only matter for the compensation term; skip them when it is off. + let own = if comp > 0.0 { + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + grain_stats_boxes(&b.img, &own_boxes) + } else { + GrainStats::default() + }; + let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -147,8 +233,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); - if view == 0 { - crate::noise::preview_compose(ctx, &mut b, &orig); + if let Some(orig) = &orig { + crate::noise::preview_compose(ctx, &mut b, orig); } b } @@ -306,6 +392,63 @@ mod tests { img } + #[test] + fn boxed_grain_stats_match_the_masked_ones() { + // Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and + // bottom-right corner cases (clamped crops). + let mut img = noisy(64, 48, 0.2, 9); + for y in 0..img.height { + for x in 0..img.width { + if (x / 7 + y / 5) % 6 == 0 { + let mut p = img.get(x as i64, y as i64); + p[3] = 0.0; + img.set(x, y, p); + } + } + } + let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)]; + let w = img.width as usize; + let mut mask = vec![false; img.data.len()]; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + mask[yy * w + xx] = true; + } + } + } + let masked = grain_stats_masked(&img, Some(&mask)); + let boxed = grain_stats_boxes(&img, &boxes); + for k in 0..3 { + assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}"); + } + assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}"); + // A denser grid where every box overlaps its neighbours (mask dedup across boxes). + let img2 = noisy(96, 64, 0.15, 21); + let mut boxes2 = Vec::new(); + for gy in 0..5 { + for gx in 0..6 { + boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12)); + } + } + let w2 = img2.width as usize; + let mut mask2 = vec![false; img2.data.len()]; + for &(x, y, s) in &boxes2 { + for yy in y..y + s { + for xx in x..x + s { + mask2[yy * w2 + xx] = true; + } + } + } + let masked2 = grain_stats_masked(&img2, Some(&mask2)); + let boxed2 = grain_stats_boxes(&img2, &boxes2); + for k in 0..3 { + assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}"); + } + assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}"); + // No boxes measures the whole image, like no mask. + assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img)); + } + #[test] fn match_grain_transfers_grain_strength() { let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index e61357c9f..6804ea2c7 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool /// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate, /// the mode and the model. The prompt is in each frame's key. pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 { + seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325; fnv(&mut h, ID.as_bytes()); fnv(&mut h, s(params, INPUT_KEY).as_bytes()); @@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn fnv(&mut h, &v.to_bits().to_le_bytes()); } fnv(&mut h, &[tracks(params) as u8]); - fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + fnv(&mut h, model.as_bytes()); h } @@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64 /// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, /// model), the prompt, the tracked span and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { - let mut h = seed(params, layer_size, fps, model); + frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { + let mut h = seed_of(params, layer_size, fps, model); let base = reference_frame(params); - if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) { fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); } for v in [base, reach(params)[0], reach(params)[1]] { @@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool { } /// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still -/// match ([`frozen_key`]) and `f` is one of them. +/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model` +/// or under the model that froze them (which this session may not have loaded, e.g. a fresh +/// headless render): frozen mattes need no segmentation. pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { let j = s(params, FROZEN); if j.is_empty() { @@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: } let fc = frozen_cache(j)?; if fc.key != frozen_key(params, layer_size, fps, model) { - return None; + let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id)); + if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) { + return None; + } } let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; @@ -705,4 +720,28 @@ mod tests { p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } + + /// Mattes frozen with a model stay usable in a session where that model is not loaded (a + /// fresh headless render): they are used as stored, not re-segmented with another model. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut p = params(&[ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([10.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 70.0])), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into())); + let stub = Stub::default(); + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some()); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + } } diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs index aabbcdce8..5716a2dcb 100644 --- a/crates/effects/src/psim.rs +++ b/crates/effects/src/psim.rs @@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync { /// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the /// CPU drops births at its live-particle cap, which an independent simulation can't know). pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { - let per = rate / SPS; - if per * steps as f64 > cap as f64 + 2.0 { + if rate / SPS * steps as f64 > cap as f64 + 2.0 { return None; } + births_at(steps, cap, |_| rate) +} + +/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a +/// keyframed Birth Rate, #1373). +pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option> { let mut out = Vec::new(); let mut carry = 0.0f64; for i in 0..steps { - carry += per; + carry += rate(i) / SPS; let n = carry.floor() as u32; carry -= n as f64; - for _ in 0..n { - out.push(i as u32); + if n as usize > cap.saturating_sub(out.len()) { + return None; } + out.extend(std::iter::repeat_n(i as u32, n as usize)); } - (out.len() <= cap).then_some(out) + Some(out) } /// Steps per second of the stepped simulations. diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 3a0743af2..79a225f75 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul}; +use crate::util::{SimCache, hash1, params_key_except, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; mod common; @@ -28,11 +28,13 @@ mod weather; pub(crate) use common::{h, hs, pct, pt, spec}; pub use engine::particle_state; #[cfg(test)] -pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; +use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates}; #[cfg(test)] -use engine::{PW_EFFECT_CAMERA, sim_key}; +pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; pub(crate) use engine::{SPS, steps_at}; pub use particle_fx::{Blob, mercury_blobs}; +#[cfg(test)] +use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; pub use registry::{specs, sprite_plan}; pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; pub(crate) use sprites::{combine, raster, splat}; diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index b379636ce..beda8c318 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -1,5 +1,5 @@ use super::common::{h, hs}; -use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys}; +use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, ps2_phys, pw_phys}; use super::sprites::{Shape, Sprite}; use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise}; @@ -29,6 +29,14 @@ pub(crate) struct PState { pub parts: Vec, pub carry: f64, pub next_id: u32, + /// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed + /// from with the same history. + pub hist: u64, +} + +/// `hist` after one more step at `rate` particles per second. +fn rates_hash(hist: u64, rate: f64) -> u64 { + (hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5) } /// Physics configuration (world units; y down). @@ -106,7 +114,15 @@ impl Phys { Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id } } + /// One step at the constant [`Phys::rate`] (the tests' reference). + #[cfg(test)] pub(crate) fn step(&self, st: &mut PState, step: u64) { + self.step_at_rate(st, step, self.rate); + } + + /// One step with `rate` particles per second born during it (rather than [`Phys::rate`]): + /// the rate only decides the births, particles already alive move and age as ever. + pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) { let dt = (1.0 / SPS) as f32; let t = step as f32 * dt; let mut drag = self.resistance; @@ -164,7 +180,8 @@ impl Phys { q.age += dt; }); st.parts.retain(|q| q.age < q.life); - st.carry += self.rate / SPS; + st.hist = rates_hash(st.hist, rate); + st.carry += rate / SPS; let n = st.carry.floor() as u32; st.carry -= n as f64; for _ in 0..n { @@ -211,42 +228,111 @@ impl Phys { } /// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or -/// `None` (simulate on the CPU). -pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { +/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time +/// (else [`Phys::rate`]). +pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option> { let backend = ctx.env.host?.particles()?; let steps = steps_at(ctx.time); - let births = crate::psim::births(phys.rate, steps, phys.max)?; + let births = match rates { + Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?, + None => crate::psim::births(phys.rate, steps, phys.max)?, + }; let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; let parts = backend.simulate(&req)?; Some(std::sync::Arc::new(PState { parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), carry: 0.0, next_id: births.len() as u32, + hist: 0, })) } +/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter +/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only +/// decides the births of its own time, #1373). Without a host that evaluates the effect at +/// other times, the current value for every step. +pub(super) fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec { + let now = ctx.params.f(id); + // A rate without keyframes or an expression is the same at every step. + let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id)); + (0..steps_at(ctx.time)) + .map(|i| { + let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64()); + if v.is_finite() { v.max(0.0) * k } else { 0.0 } + }) + .collect() +} + +/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with +/// `cache`, resuming only from checkpoints reached with the same rates. +pub(super) fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc { + // `hist` of the state after each number of steps. + let mut prefix = Vec::with_capacity(rates.len() + 1); + prefix.push(0u64); + for r in rates { + prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r)); + } + let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0); + cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist)) +} + +/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own +/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every +/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under +/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it. +pub(super) struct Stepped<'a> { + pub(super) cache: &'a SimCache, + pub(super) salt: u64, + pub(super) skip: &'a [&'a str], + pub(super) k: f64, + pub(super) gpu: bool, +} + +impl Stepped<'_> { + pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + let rates = rate_history(ctx, "birthRate", self.k); + let mut skip = self.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip); + let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None }; + gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates)) + } +} + +/// CC Particle World. +const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true }; +/// CC Particle Systems II. +pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true }; +/// CC Mr. Mercury (its blobs are simulated on the CPU). +pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false }; + +/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time. +pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + PW.state(ctx, phys) +} + /// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, /// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has /// one (GPU particles), else from the CPU simulation. For comparing the two. pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { - let (phys, cache, salt) = match id { - "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), - "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + let st = match id { + "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)), + "ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)), _ => return None, }; - let key = sim_key(ctx, salt); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) } -/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's -/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart -/// the simulation). +/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests' +/// reference): every parameter but CC Particle World's Effect Camera. +#[cfg(test)] pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 { params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA]) } -/// Run a particle system to layer time `t` through `cache`. +/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the +/// tests' reference). +#[cfg(test)] pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) } diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index 6b6c27b01..440951c83 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -1,16 +1,19 @@ -use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite}; +use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite}; use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; -use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul}; +use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul}; // ------------------------------------------------------------------ CC Particle World pub(super) static PW_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle World's Birth Rate. +pub(super) const PW_RATE: f64 = 120.0; + /// CC Particle World's physics. pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; Phys { - rate: pr.f("birthRate").max(0.0) * 120.0, + rate: pr.f("birthRate").max(0.0) * PW_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32], @@ -152,8 +155,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option, mode: u32) -> SpritePlan { pub(super) static PS2_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle Systems II's Birth Rate. +pub(super) const PS2_RATE: f64 = 60.0; + /// CC Particle Systems II's physics. pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; @@ -198,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys { let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); Phys { - rate: pr.f("birthRate").max(0.0) * 60.0, + rate: pr.f("birthRate").max(0.0) * PS2_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0], @@ -225,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); - let phys = ps2_phys(ctx); - let key = sim_key(ctx, 2); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); + let st = PS2.state(ctx, &ps2_phys(ctx)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -250,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { // ------------------------------------------------------------------ CC Mr. Mercury -static MERCURY_CACHE: SimCache = SimCache::new(6); +pub(super) static MERCURY_CACHE: SimCache = SimCache::new(6); + +/// Blobs per second per unit of CC Mr. Mercury's Birth Rate. +pub(super) const MERCURY_RATE: f64 = 10.0; /// A CC Mr. Mercury blob (buffer px). #[derive(Clone, Copy, Debug)] @@ -260,15 +266,14 @@ pub struct Blob { pub r: f32, } -/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). -pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { +/// CC Mr. Mercury's physics. +pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let lh = ctx.layer_size[1] as f32; - let unit = lh.max(1.0); + let unit = (ctx.layer_size[1] as f32).max(1.0); let pos = pr.v2("producer"); let dir = (pr.f("direction") as f32).to_radians(); - let phys = Phys { - rate: pr.f("birthRate").max(0.0) * 10.0, + Phys { + rate: pr.f("birthRate").max(0.0) * MERCURY_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0], @@ -282,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { seed: ctx.seed.wrapping_mul(0xc2b2ae35), max: 4_000, flat: true, - }; - let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); + } +} + +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { + let pr = ctx.params; + let unit = (ctx.layer_size[1] as f32).max(1.0); + let phys = mercury_phys(ctx); + let st = MERCURY.state(ctx, &phys); let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); let s = b.scale as f32; st.parts diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index c6f83ab4e..e368c7bbe 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -405,3 +405,266 @@ fn half_resolution_scales_sprites() { let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); } + +// ---------------------------------------------------------- Birth Rate over time (#1373) + +/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`. +struct Rate { + index: usize, + rate: fn(f64) -> f64, +} +impl crate::EffectHost for Rate { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option { + (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t))) + } +} + +/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst). +fn burst(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// The same burst, cut at 0.25 s (an earlier key moved). +fn short_burst(t: f64) -> f64 { + if t < 0.25 { 3.0 } else { 0.0 } +} + +/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s. +fn falling(t: f64) -> f64 { + (3.0 * (1.0 - t / 0.5)).max(0.0) +} + +/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the +/// rendered time), Longevity 1 s. +fn pw_params(seed: f64, now: f64) -> crate::Params { + let spec = crate::find("ec.sim.ccparticleworld").unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] { + p.values.insert(k.to_string(), v); + } + p +} + +fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env } +} + +/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history. +fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec { + let p = pw_params(seed, now); + particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec { + let p = pw_params(seed, now); + let ctx = pw_ctx(&p, t, Some(host), host.index); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); + let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE)); + st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect() +} + +/// Particles born in the first `t` seconds of `rate`. +fn born_by(rate: fn(f64) -> f64, t: f64) -> usize { + crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len() +} + +/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the +/// particles born in it live on (moving and ageing) until their Longevity runs out, rather +/// than vanishing because the rate is now 0. +#[test] +fn a_burst_lives_on_after_birth_rate_drops_to_zero() { + let host = Rate { index: 2, rate: burst }; + // During the burst: as a constant Birth Rate of 3. + assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None)); + // After it: the burst's particles, none newer. + let late = pw_at(101.0, 0.0, 0.9, Some(&host)); + let n = born_by(burst, 0.5); + assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len()); + assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any"); + // They keep moving. + let early = pw_at(101.0, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.id == late[0].id).unwrap(); + assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]); + // Longevity 1 s (± 20 %): all gone by 1.8 s. + assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty()); + // The effect asks for its own index: another effect's history isn't its own. + let p = pw_params(101.0, 0.0); + assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty()); +} + +/// A Birth Rate ramping down isn't simulated as if its current value had always applied. +#[test] +fn a_ramped_birth_rate_bears_what_each_moment_asked_for() { + let host = Rate { index: 0, rate: falling }; + let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host)); + let constant = pw_at(102.0, falling(0.25), 0.25, None); + assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive"); + assert_eq!(constant.len(), born_by(|_| 1.5, 0.25)); + assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len()); +} + +/// Seeking backward, forward and to the same time again gives the state simulated from 0. +#[test] +fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() { + let host = Rate { index: 0, rate: burst }; + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}"); + } +} + +/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints), +/// but a checkpoint is only resumed with the history that reached it: after an earlier key +/// moves, the same current rate (0) gives the new history's particles. +#[test] +fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + let a = pw_at(104.0, 0.0, 0.9, Some(&long)); + let b = pw_at(104.0, 0.0, 0.9, Some(&short)); + assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short)); + assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len()); + assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long)); +} + +/// A constant Birth Rate gives exactly the particles it did before the history was sampled. +#[test] +fn a_constant_birth_rate_is_unchanged() { + let host = Rate { index: 0, rate: |_| 2.0 }; + let p = pw_params(105.0, 2.0); + let ctx = pw_ctx(&p, 1.3, None, 0); + let phys = pw_phys(&ctx); + let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys); + let now = pw_at(105.0, 2.0, 1.3, Some(&host)); + assert_eq!(now.len(), before.parts.len()); + assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age)); + assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000)); +} + +/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the +/// CPU simulation numbers. +#[test] +fn the_birth_schedule_follows_the_rate_history() { + let host = Rate { index: 0, rate: falling }; + let p = pw_params(106.0, 0.0); + let ctx = pw_ctx(&p, 0.7, Some(&host), 0); + let rates = rate_history(&ctx, "birthRate", PW_RATE); + let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates); + let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap(); + assert_eq!(st.next_id as usize, births.len()); + assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); +} + +// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time + +/// A stepped particle effect: its id, physics and simulation. +struct System { + id: &'static str, + phys: fn(&EffectCtx) -> Phys, + stepped: Stepped<'static>, +} + +const SYSTEMS: [System; 2] = + [System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }]; + +/// `id`'s parameters with Birth Rate `now` and Longevity 1 s. +fn sys_params(id: &str, now: f64) -> crate::Params { + let spec = crate::find(id).unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] { + assert!(p.values.contains_key(k), "{id}: no {k}"); + p.values.insert(k.to_string(), v); + } + p +} + +/// The context at layer time `t` (`seed` keeps each test's simulations apart). +fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env } +} + +/// The live particles (id, position, age) of `sys` at `t` through its simulation. +fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, Some(host), seed); + let mut skip = sys.stepped.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip); + let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k)); + st.parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs. +fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + match sys.id { + "ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(), + id => particle_state(id, &ctx).unwrap().len(), + } +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate +/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() { + let host = Rate { index: 1, rate: burst }; + for sys in &SYSTEMS { + let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len(); + assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id); + let late = sys_at(sys, 201, 0.0, 0.9, Some(&host)); + assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len()); + assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id); + assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id); + let early = sys_at(sys, 201, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap(); + assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]); + assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id); + } +} + +/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same +/// cache key) isn't served from the old history's checkpoints. +#[test] +fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + for sys in &SYSTEMS { + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id); + } + let b = sys_at(sys, 202, 0.0, 0.9, Some(&short)); + assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id); + assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id); + } +} + +/// A constant Birth Rate gives exactly the particles the constant-rate simulation did. +#[test] +fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() { + let host = Rate { index: 0, rate: |_| 2.0 }; + for sys in &SYSTEMS { + let p = sys_params(sys.id, 2.0); + let ctx = sys_ctx(&p, 1.3, None, 203); + let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx)); + let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect(); + assert!(!before.is_empty()); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id); + } +} diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 403c325cd..e28f9a4fa 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { let rr = (2.0 / 3.0 * y) / r; // Cube rounding. let (cx, cz) = (q, rr); - let cy = -cx - cz; + let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r; let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); if dx > dy && dx > dz { diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index ae55d095d..9c81dab10 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread: +/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes). +const PAR_MIN: usize = 4096; + /// A single-channel `f32` image (row-major). #[derive(Clone, Debug, Default, PartialEq)] pub struct Plane { @@ -62,6 +66,15 @@ impl Plane { pub fn transpose(&self) -> Plane { let (w, h) = (self.w, self.h); let mut out = Plane::new(h, w); + if w.saturating_mul(h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the copy itself. + for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() { + for y in 0..h { + col[y] = self.data[y * w + x]; + } + } + return out; + } out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { for y in 0..h { col[y] = self.data[y * w + x]; @@ -75,6 +88,11 @@ impl Plane { if self.w == 0 { return out; } + if self.w.saturating_mul(self.h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the rows themselves. + out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o)); + return out; + } out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o)); out } @@ -534,14 +552,29 @@ impl SimCache { /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step /// `i` to `i + 1`. - pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + self.run_where(key, target, init, step, |_, _| true) + } + + /// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a + /// parameter's values over time): a checkpoint after `s` steps is only resumed from when + /// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace + /// those at the same step. + pub fn run_where( + &self, + key: u64, + target: u64, + init: impl FnOnce() -> S, + mut step: impl FnMut(&mut S, u64), + valid: impl Fn(u64, &S) -> bool, + ) -> std::sync::Arc { use std::sync::Arc; let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; g.iter_mut().find(|e| e.key == key).and_then(|e| { e.used = tick; - e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) }) }; let had_start = start.is_some(); @@ -579,9 +612,8 @@ impl SimCache { let e = &mut g[idx]; e.used = tick; for (s, st) in fresh { - if !e.checkpoints.iter().any(|(c, _)| *c == s) { - e.checkpoints.push((s, st)); - } + e.checkpoints.retain(|(c, _)| *c != s); + e.checkpoints.push((s, st)); } // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. if e.checkpoints.len() > 256 { diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index 226654a04..8d9fe6f0e 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings { } } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - struct KeyHasher(u64); impl Hasher for KeyHasher { fn finish(&self) -> u64 { @@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 { h.finish() } -type AnalysisCache = Mutex>>; type PlanCache = Mutex>>; -/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate). +/// Parsed analyses and their content keys; cache hits compare source text without rehashing. fn analysis_cached(json: &str) -> Option<(u64, Arc)> { - static C: OnceLock = OnceLock::new(); - let key = fnv(json.as_bytes()); - let c = C.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some((key, a.clone())); - } - let a = Arc::new(WarpAnalysis::from_json(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some((key, a)) + static C: crate::analysis_cache::AnalysisCache = crate::analysis_cache::AnalysisCache::new(); + C.get(json, WarpAnalysis::from_json) } fn plans() -> &'static PlanCache { @@ -223,6 +202,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { p } +/// Validate and cache the default stabilization plan after analysis. Use the serialized +/// analysis so the cached plan has exactly the same inputs as the first render, including +/// any floating-point round trip through JSON. Other settings still get their own plan. +pub fn prepare_default_plan(json: &str) -> bool { + let Some((key, a)) = analysis_cached(json) else { return false }; + if a.is_empty() { + return false; + } + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() }); + p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite())) +} + /// The stored analysis of an instance (`None` when not analysed). pub fn analysis(params: &Params) -> Option> { let j = s(params, ANALYSIS); @@ -531,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) { mod tests { use super::*; use crate::EffectEnv; + use crate::analysis_cache::fnv; use effectcraft_track::stabilize::FrameMotion; fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String { @@ -601,6 +594,61 @@ mod tests { assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data); } + #[test] + fn prepared_default_plan_preserves_first_render_and_is_reused() { + let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 0.012345678901234567; + a.tracks = (0..24) + .map(|id| effectcraft_track::camtrack::Track2D { + id, + start: 0, + pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(), + }) + .collect(); + let json = a.to_json(); + let ps = params(&json, 2, 0); + let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() }; + let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 }; + // Original flow: validate, discard the plan, then render from the stored analysis. + let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() }); + let before = render(&ctx, input.clone()); + let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0))); + plans().lock().unwrap().remove(&key); + assert!(prepare_default_plan(&json)); + let prepared = plans().lock().unwrap().get(&key).unwrap().clone(); + assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan"); + let after = render(&ctx, input); + let (_, _, rendered) = plan_at(&ps, a.start).unwrap(); + assert!(Arc::ptr_eq(&prepared, &rendered)); + assert_eq!(before.offset, after.offset); + assert_eq!(before.scale.to_bits(), after.scale.to_bits()); + for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + assert!(!prepare_default_plan("invalid")); + assert!(!prepare_default_plan(&WarpAnalysis::default().to_json())); + } + + #[test] + fn changing_analysis_changes_the_plan_key() { + let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]); + let mut ps = params(&json, 2, 0); + let before = summary_key(&ps).unwrap(); + assert_eq!(before[0], fnv(json.as_bytes())); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 1.0; + ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json())); + let after = summary_key(&ps).unwrap(); + assert_ne!(before[0], after[0]); + assert_eq!(before[1], after[1]); + assert_eq!(*analysis(&ps).unwrap(), a); + ps.values.insert(ANALYSIS.into(), Value::Str(json)); + assert_eq!(summary_key(&ps).unwrap(), before); + ps.values.insert(ANALYSIS.into(), Value::Str(String::new())); + assert!(analysis(&ps).is_none()); + } + #[test] fn nested_param_lookup() { let mut p = Params::default(); diff --git a/crates/engine/presets/behaviors.json b/crates/engine/presets/behaviors.json new file mode 100644 index 000000000..71e87db52 --- /dev/null +++ b/crates/engine/presets/behaviors.json @@ -0,0 +1,248 @@ +{ + "format": "effectcraft.animationPresets/1", + "notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.", + "presets": [ + { + "id": "wiggleSoft", + "name": "Soft Wiggle", + "category": "Behaviors", + "description": "The layer wanders gently around its position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]" + } + } + }, + { + "id": "shake", + "name": "Shake", + "category": "Behaviors", + "description": "A fast, tight shake of the layer's position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(14, 12); [w[0], w[1]]" + } + } + }, + { + "id": "floatUpDown", + "name": "Float", + "category": "Behaviors", + "description": "The layer bobs up and down like it floats on water.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]" + } + } + }, + { + "id": "sway", + "name": "Sway", + "category": "Behaviors", + "description": "The layer sways left and right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]" + } + } + }, + { + "id": "orbit", + "name": "Orbit", + "category": "Behaviors", + "description": "The layer travels round a circle around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]" + } + } + }, + { + "id": "figureEight", + "name": "Figure Eight", + "category": "Behaviors", + "description": "The layer traces a figure eight around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]" + } + } + }, + { + "id": "bouncingBall", + "name": "Bouncing Ball", + "category": "Behaviors", + "description": "The layer bounces up from its position again and again.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]" + } + } + }, + { + "id": "driftRight", + "name": "Drift Right", + "category": "Behaviors", + "description": "The layer drifts steadily to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*40), value[1]+(0)]" + } + } + }, + { + "id": "driftDiagonal", + "name": "Drift Diagonal", + "category": "Behaviors", + "description": "The layer drifts slowly down and to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*30), value[1]+(time*18)]" + } + } + }, + { + "id": "pendulum", + "name": "Pendulum", + "category": "Behaviors", + "description": "The layer swings like a pendulum.", + "layer": { + "expr": { + "transform/rotation": "value + Math.sin(time*2.4)*25" + } + } + }, + { + "id": "wobble", + "name": "Wobble", + "category": "Behaviors", + "description": "The layer rotates with an irregular wobble.", + "layer": { + "expr": { + "transform/rotation": "wiggle(2, 10)" + } + } + }, + { + "id": "spinSlow", + "name": "Spin Slow", + "category": "Behaviors", + "description": "The layer rotates once every ten seconds.", + "layer": { + "expr": { + "transform/rotation": "value + time*36" + } + } + }, + { + "id": "spinFast", + "name": "Spin Fast", + "category": "Behaviors", + "description": "The layer rotates twice a second.", + "layer": { + "expr": { + "transform/rotation": "value + time*720" + } + } + }, + { + "id": "pulse", + "name": "Pulse", + "category": "Behaviors", + "description": "The layer swells and shrinks smoothly.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]" + } + } + }, + { + "id": "heartbeat", + "name": "Heartbeat", + "category": "Behaviors", + "description": "Two quick beats, then a rest, repeating every second.", + "layer": { + "expr": { + "transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]" + } + } + }, + { + "id": "breathe", + "name": "Breathe", + "category": "Behaviors", + "description": "The layer slowly grows and settles back.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]" + } + } + }, + { + "id": "rubberBand", + "name": "Rubber Band", + "category": "Behaviors", + "description": "Width and height stretch against each other.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]" + } + } + }, + { + "id": "jitterScale", + "name": "Jitter Scale", + "category": "Behaviors", + "description": "The layer's size jitters randomly.", + "layer": { + "expr": { + "transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]" + } + } + }, + { + "id": "blink", + "name": "Blink", + "category": "Behaviors", + "description": "The layer switches on and off twice a second.", + "layer": { + "expr": { + "transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0" + } + } + }, + { + "id": "strobe", + "name": "Strobe", + "category": "Behaviors", + "description": "The layer flashes on for a few frames each beat.", + "layer": { + "expr": { + "transform/opacity": "((time*6) % 1 < 0.25) ? value : 0" + } + } + }, + { + "id": "flicker", + "name": "Flicker", + "category": "Behaviors", + "description": "The layer's opacity flickers like a failing lamp.", + "layer": { + "expr": { + "transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))" + } + } + }, + { + "id": "fadePulse", + "name": "Fade Pulse", + "category": "Behaviors", + "description": "Opacity breathes between full and a third.", + "layer": { + "expr": { + "transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))" + } + } + } + ] +} diff --git a/crates/engine/presets/behaviors.json.attribution b/crates/engine/presets/behaviors.json.attribution new file mode 100644 index 000000000..7c71c62e8 --- /dev/null +++ b/crates/engine/presets/behaviors.json.attribution @@ -0,0 +1,7 @@ +asset: behaviors.json +title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510) +notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values. diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 517e0af79..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -208,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 5472d4e8c..71ee45ee6 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -4,7 +4,7 @@ //! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out //! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the -//! stabilization plan once to validate the result. When the job finishes, the analysis is +//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that //! change those clear the analysis (`invalidate`, run after every edit) and queue the effect @@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; -use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis}; +use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; use crate::tracking::lock; @@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) { let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let a = an.finish(lt0, fd); - // Step 2: derive a plan once (validates the motion and warms nothing else up). - let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() }); - if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) { + // Plan from the stored JSON, exactly as rendering does, and retain it for the first render. + if !warp_stab::prepare_default_plan(&a.to_json()) { return fail("the stabilization could not be computed"); } *lock(&shared.result) = Some(a); diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 325845115..e711392e0 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { clip.push(KeyClip { layer: lid, path, keys }); } let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]); s.state.key_clipboard = clip; s.state.clip_is_keys = true; Ok(json!(n)) diff --git a/crates/engine/src/cmd/anim_presets.rs b/crates/engine/src/cmd/anim_presets.rs index fc3f95143..88022a0e3 100644 --- a/crates/engine/src/cmd/anim_presets.rs +++ b/crates/engine/src/cmd/anim_presets.rs @@ -1,6 +1,7 @@ //! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the //! Transitions category (original presets built on our Transition effects and layer opacity) and -//! the Backgrounds category (generators, noise and colour effects driven by expressions). +//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the +//! Behaviors category (transform and opacity driven by expressions, no effects). //! //! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting //! at the current time, on every selected layer. The library is data @@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json" /// The original background presets (`presets/backgrounds.json`). pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json"); +/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's +/// transform and opacity, no effects. +pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json"); + /// Keys per animated property, so a hostile inline preset can't allocate without bound. const MAX_KEYS: usize = 256; /// Longest expression text an inline preset may carry. const MAX_EXPR: usize = 4096; fn presets() -> Vec { - [TRANSITIONS_JSON, BACKGROUNDS_JSON] + [TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON] .iter() .flat_map(|src| serde_json::from_str::(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) .collect() diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 1bdf3fed7..3f9c195c2 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, use effectcraft_time::Tick; use serde_json::{Value, json}; +use super::effect::{effect_time_error, retime_effect}; use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, Result, Session, cmd}; /// Preset file format version tag (`*.ecpreset`, JSON). pub const PRESET_TAG: &str = "effectcraftAnimationPreset"; +/// The preset format version that `anim.savePreset` writes. +pub const PRESET_VERSION: u64 = 2; +/// The first preset version that stores effect keys relative to the earliest key (#1971). +const RELATIVE_EFFECT_KEYS: u64 = 2; fn has_selected_props(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; @@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> { .collect() } +/// The earliest key anywhere in `groups`, nested controls included. +fn earliest_key<'a>(groups: impl IntoIterator) -> Option { + let mut first: Option = None; + for g in groups { + g.walk("", &mut |_, pr| { + if let Some(t) = pr.keys.iter().map(|k| k.time).min() { + first = Some(first.map_or(t, |f| f.min(t))); + } + }); + } + first +} + // ---------------------------------------------------------------- presets fn save_preset(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string(); let comp = s.active_comp().ok_or(EngineError::NoComp)?; let t = s.time(); - let effects: Vec<(LayerId, PropGroup)> = selected_effects(s); + let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s); let effect_uids: Vec = effects.iter().map(|(_, g)| g.uid).collect(); let mut props = vec![]; for (lid, uid) in selected_leaf_props(s) { @@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { return Err(bad("anim.savePreset", "select properties or effects to save")); } // Key times are stored relative to the earliest key so the preset applies at the CTI. - let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO); + let first = + props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO); + for (_, g) in &mut effects { + retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?; + } let props_json: Vec = props .iter() .map(|(_, mp, pr)| { @@ -82,12 +104,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name") .map(str::to_string) .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()); - let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json}); + let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json}); let text = serde_json::to_string_pretty(&doc).unwrap_or_default(); s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) } +/// Rewrite an effect property path in a preset (`effects/[#n]/`) to the current +/// parameter id when it names a parameter that the effect has since renamed or moved into a +/// twirl-down group. Any other path comes back unchanged. +fn upgrade_effect_path(path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let effect = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m, + _ => seg, + }; + match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) { + Some(id) if id != tail => format!("effects/{seg}/{id}"), + _ => path.to_string(), + } +} + fn apply_preset(s: &mut Session, p: &Value) -> Result { let doc: Value = match (p.get("preset"), str_p(p, "path")) { (Some(v), _) => v.clone(), @@ -110,14 +147,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { s.save_prefs(); s.prefs_revision += 1; } - let effects: Vec = + let mut effects: Vec = doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); + // Older presets stored effect keys at the source layer's times. + if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS + && let Some(first) = earliest_key(&effects) + { + for g in &mut effects { + retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; + } + } let props: Vec<(String, Property)> = doc["properties"] .as_array() .map(|a| { a.iter() .filter_map(|v| { - let path = v["path"].as_str()?.to_string(); + let path = upgrade_effect_path(v["path"].as_str()?); let value: KV = serde_json::from_value(v["value"].clone()).ok()?; let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); @@ -130,16 +175,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { }) .unwrap_or_default(); let t = s.time(); + // Effect bounds per layer size the point defaults of parameters added since the preset was saved. + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sizes: BTreeMap = + ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect(); let mut applied = 0; let mut skipped = vec![]; s.edit("Apply Animation Preset", None, |proj, _| { let mut next = proj.next_id; for lid in &ids { + let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]); let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(fx) = l.props.sub_mut("effects") { for g in &effects { let mut g = g.clone(); + // Effects saved by an earlier version get the upgrade a project gets on open. + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + if let Some(spec) = spec { + effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size); + } + retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; g.reassign_uids(&mut next); next += 1; fx.children.push(Node::Group(g)); diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..5bd897ba9 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { 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_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).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()})) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index d6de320ec..478dc8259 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -5,7 +5,7 @@ use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; use crate::prefs::CompPreset; use crate::{EngineError, Result, Session, cmd, query}; @@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { - use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan}; use effectcraft_project::render_templates::TemplateSlot; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if b_p(p, "transparent") == Some(false) { + it.output.channels = Channels::Rgb; + } if let Some(path) = str_p(p, "path") { it.output.output = path.to_string(); } else if !it.output.format.is_sequence() { @@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { } fn save_frame(s: &mut Session, p: &Value) -> Result { - if p.get("queue").and_then(Value::as_bool) == Some(true) { + if b_p(p, "queue") == Some(true) { return queue_frame(s, p); } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); @@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved frame to {path}")); @@ -235,7 +238,7 @@ pub fn specs() -> Vec { "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), - "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}", has_comp, save_frame ), diff --git a/crates/engine/src/cmd/content_fill.rs b/crates/engine/src/cmd/content_fill.rs index 650df5e73..d96be073d 100644 --- a/crates/engine/src/cmd/content_fill.rs +++ b/crates/engine/src/cmd/content_fill.rs @@ -170,6 +170,21 @@ pub struct FillPlan { pub dir: String, } +/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled +/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to +/// report up front, instead of running out of memory partway through. +fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option { + let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33); + (need > available).then(|| { + let gb = |b: u64| b as f64 / 1e9; + format!( + "Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications", + gb(need), + gb(available) + ) + }) +} + /// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the /// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files. #[allow(clippy::too_many_arguments)] @@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result { } let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1))); let times: Vec = (fa..=fb).map(|f| fr.tick_of(f)).collect(); + if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) { + return Err(bad(c, e)); + } let opts = FillOpts { method: FillMethod::from_name(&st.method).unwrap_or_default(), lighting: lighting(&st.lighting_correction).unwrap_or(0.0), @@ -424,3 +442,18 @@ pub fn specs() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::memory_shortfall; + + /// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold. + #[test] + fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() { + let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused"); + assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}"); + assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits"); + // Hostile sizes saturate instead of overflowing. + assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some()); + } +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 850d38ada..6c4b02c87 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, -//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). +//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer +//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to). use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result { let time = s.time(); let mut created = vec![]; let mut skipped = vec![]; + // All the selected models in one edit: one undo (or redo) removes (or restores) every + // instance at once, as one command in the History panel. + if op == "instance3d" { + // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take + // no masks or effects, so a comp-sized solid goes at the top of the layer stack + // with a 3D Layer Instance effect referencing the model — a 2D copy of its render, + // through the comp's camera, that 2D effects can be applied to. The model is switched + // off, as After Effects does (back on to keep its shadow). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut eligible = vec![]; + for lid in &lids { + match comp.layer(*lid) { + Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())), + Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)), + None => {} + } + } + if !eligible.is_empty() { + let ids = s.edit("Create 3D Layer Instance", None, |proj, st| { + let mut ids = vec![]; + for (lid, src) in &eligible { + let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + src.label, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + // A soloed source keeps its Solo switch with video off, and any_solo hides + // the non-soloed copy: the copy takes the Solo over to stay visible. + l.switches.solo = src.switches.solo; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?; + let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("layer") { + pr.value = KV::Layer(Some(lid.0)); + } + if let Some(fxg) = l.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + // The model is switched off (After Effects turns the referenced layer off); + // the copies go at the top of the stack, selected. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) { + m.switches.video = false; + } + let id = l.id; + c.layers.insert(0, l); + ids.push(id); + } + st.selected_layers = ids.clone(); + Ok(ids) + })?; + created.extend(ids.iter().map(|i| i.0)); + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + return Ok(json!({"layers": created, "skipped": skipped})); + } for lid in lids { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let Some(src) = comp.layer(lid).cloned() else { continue }; @@ -198,5 +264,5 @@ fn insert_above( } pub fn specs() -> Vec { - vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0130aff70..30c027deb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); @@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { st.selected_layers = created.clone(); Ok(created) })?; + // Duplicating selects the copies, so an open text edit on a now-deselected layer ends + // (#2226): typing must not keep editing an unselected layer. + if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) { + super::text_edit::end_edit(s, &Value::Null)?; + } Ok(json!(new.iter().map(|l| l.0).collect::>())) } @@ -263,6 +268,7 @@ fn clear_clipboards(s: &mut Session) { s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.value_clipboard.clear(); + s.state.keyframe_text = None; s.state.clip_is_keys = false; } @@ -329,10 +335,12 @@ fn copy_properties(s: &mut Session) -> Result> { } let mut keys = s.state.selected_keys.clone(); let mut values = vec![]; + let mut statics = vec![]; for (lid, uid) in &leaves { let Some(l) = comp.layer(*lid) else { continue }; let Some(pr) = l.props.find(*uid) else { continue }; if pr.keys.is_empty() { + statics.push((*lid, *uid)); if let Some(path) = l.props.match_path_of(*uid) && !values.iter().any(|(p, _)| *p == path) { @@ -342,6 +350,7 @@ fn copy_properties(s: &mut Session) -> Result> { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } + let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics); clear_clipboards(s); let mut n = 0; if !keys.is_empty() { @@ -350,6 +359,7 @@ fn copy_properties(s: &mut Session) -> Result> { s.state.selected_keys = selected; n = r?.as_u64().unwrap_or(0); } + s.state.keyframe_text = text; let v = values.len(); s.state.value_clipboard = values; // Keys alone answer as `keys.copy` does (their count). diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 0a1354fd6..32b0aaa2b 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -288,7 +306,16 @@ impl CryptoProbe { let comp = r.project.comp(cid).ok_or("no composition")?; let layer = comp.layer(lid).ok_or("no such layer")?; let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; - let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let time = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); let prefs = (o.nested_switches, o.draft_shadows); @@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; let (cid, lid, ..) = key.at; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); @@ -700,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(sel) } -fn effect_time_error(cmd: &str) -> EngineError { +pub(crate) fn effect_time_error(cmd: &str) -> EngineError { bad(cmd, "an effect keyframe converts outside the supported time range") } @@ -736,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result Result) -> Result<()> { +pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result) -> Result<()> { let mut result = Ok(()); group.walk_mut(&mut |pr| { if result.is_err() { @@ -804,6 +849,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.keyframe_text = None; s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; diff --git a/crates/engine/src/cmd/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/cmd/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..beb161a53 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// 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 { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } @@ -745,7 +746,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index c4af0846c..6358e7029 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -1029,10 +1029,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string(); let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?; let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - // A numbered still brings its image sequence, as on import (`sequence: false` for one frame). - let opts = SequenceOptions::from_params(p); + // Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run. + // `sequence`/`alphabetical` override that (#2073). + let previous = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical), + _ => (false, false, false), + }; + // Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence + // modifier, not a request to make one. + let inherited = b_p(p, "sequence").is_none(); + let mut opts = SequenceOptions::from_params(p); + if inherited { + (opts.sequence, opts.folders) = (previous.0, previous.1); + if previous.0 && b_p(p, "alphabetical").is_none() { + opts.alphabetical = previous.2; + } + } let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); - let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A single-file source comes back as a still; an inherited sequence kind keeps it a + // one-frame sequence, so a later default replacement still brings numbered runs (#2073). + if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still { + f.kind = FootageKind::Sequence; + f.sequence = vec![f.path.clone()]; + f.alphabetical = opts.alphabetical; + f.frame_rate = s.sequence_rate(); + f.native_rate = None; + f.sync_sequence_duration(); + } let name = src.name(); // Replacing a missing file relinks the other missing files that moved with it (as After Effects // does when you locate one). @@ -1217,7 +1241,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), @@ -1235,7 +1259,7 @@ pub fn specs() -> Vec { "Apply Interpretation", ["File", "Interpret Footage"], Some("Cmd+Alt+V"), - "{items?}", + "{items?|item?}", has_interpretation, apply_interpretation ), @@ -1244,7 +1268,7 @@ pub fn specs() -> Vec { "File...", ["File", "Replace Footage"], Some("Cmd+H"), - "{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}", + "{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}", has_footage_selection, replace_footage ), @@ -1266,7 +1290,7 @@ pub fn specs() -> Vec { has_footage_selection, replace_with_solid ), - cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload), + cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload), cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder), ] } diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/cmd/focus.rs +++ b/crates/engine/src/cmd/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f6d2feb04..885518fc3 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -121,7 +121,7 @@ pub fn specs() -> Vec { "Track Matte", [], None, - "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", + "{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}", has_layers, track_matte ), diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7962c2485..ccc4810a9 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option> { /// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 /// characters of the first text line, else "Text". `edit: true` also opens a new editing /// session with anchor `0` and the caret after the text, selecting all initial text so the next -/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does. pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index fbaec14e4..a6f49abf8 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -9,10 +9,20 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -22,7 +32,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -44,7 +54,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -64,34 +75,19 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); } - // Cameras and lights aim with Point of Interest: move it by the same parent-space - // change, so a two-node rig keeps its world frame (#1010, #1020). if let Some(pr) = tr.get_mut("poi") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } + map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -154,13 +150,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + budget: usize, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); +} + +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); + // With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between + // the converted keys, rather than becoming a straight blend that was never checked against the original. + if between.len() >= budget { + between.clear(); + // An eased segment, or one that gained keys, becomes a run of straight blends. + } else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, @@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; @@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - // Cameras and lights compensate like 3D layers (Position, and Point of Interest for - // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no - // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - if !orientation.keys.is_empty() - || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); + if orientation.expr.is_some() + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } @@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + // Locked layers keep their parent (several at once: the others are parented). + let ids = unlocked(s, cid, ids, "layer.setParent")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let parent = match p.get("parent") { None | Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 38689afbb..7679d34fa 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,6 +1,6 @@ use super::{ BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, - layers_p, merge_p, resolve_layer, str_p, + layers_p, merge_p, resolve_layer, str_p, unlocked, }; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { @@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left +/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps +/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers +/// at once keeps their modes. pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; + let (cid, ids) = match p.get("layers") { + Some(_) => layers_p(s, p)?, + None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?, + }; + // Locked layers keep their matte (several at once: the others get it). + let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), }; - let kind = str_p(p, "kind") - .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + if ids.is_empty() { + return Err(bad("layer.setTrackMatte", "no layers")); + } + if matte.is_some_and(|m| ids.contains(&m)) { + return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); + } + // None: each layer keeps its own mode. + let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) { + Some(k) if k == "preserve" => None, + Some(k) => Some(match k.as_str() { "alphainverted" | "alphainv" => MatteKind::AlphaInverted, "luma" => MatteKind::Luma, "lumainverted" | "lumainv" => MatteKind::LumaInverted, _ => MatteKind::Alpha, - }) - .unwrap_or(MatteKind::Alpha); + }), + None => Some(MatteKind::Alpha), + }; s.edit("Track Matte", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - if let Some(m) = matte { - if m == lid { - return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); - } - if let Some(ml) = comp.layer_mut(m) { - ml.switches.video = false; - } + if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) { + ml.switches.video = false; + } + for lid in &ids { + let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?; + let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default()); + l.track_matte = matte.map(|layer| TrackMatte { layer, kind }); } - comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); Ok(()) })?; Ok(Value::Null) } + +#[cfg(test)] +mod track_matte_batch_tests { + use super::*; + use std::sync::Arc; + + fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) + } + + /// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"` + /// keeps each one's mode; refused calls change nothing; one `layer` still works. + #[test] + fn several_layers_take_one_matte_and_keep_their_modes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before"); + let (before, undo) = (s.project.clone(), s.history.undo.len()); + s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap(); + assert_eq!( + [matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)], + [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))], + "each keeps its mode (Alpha without one)" + ); + assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); + // Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes. + for p in [ + json!({"layers": [a, y], "matte": y}), + json!({"layers": [a, 9999], "matte": y}), + json!({"layers": [], "matte": y}), + json!({"layers": [a], "matte": 9999}), + ] { + let (project, revision) = (s.project.clone(), s.revision); + assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing"); + } + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it"); + } + + /// Locked layers keep their parent and matte when they are among several (the others change), + /// and alone they are an error that changes nothing. + #[test] + fn locked_layers_keep_their_parent_and_matte() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap(); + s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap(); + assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma)))); + assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was"); + for (cmd, p) in [ + ("layer.setParent", json!({"layers": [b], "parent": p})), + ("layer.setTrackMatte", json!({"layers": [b], "matte": m})), + ("layer.trackMatte", json!({"layers": [b], "op": "alpha"})), + ] { + let (project, revision) = (s.project.clone(), s.revision); + let err = s.execute(cmd, p).unwrap_err(); + assert!(err.to_string().contains("is locked"), "{cmd}: {err}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing"); + } + } +} diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 711eefee2..78f6cdc3c 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { shift(l, d); } "trimInToTime" => l.in_point = t.min(l.out_point - fd), - "trimOutToTime" => l.out_point = t.max(l.in_point + fd), + // Alt+]: the Out edge lands one frame after the playhead, so its frame stays the + // layer's last visible one (as in After Effects), never before the In point. + "trimOutToTime" => l.out_point = t.max(l.in_point) + fd, _ => { if let Some(d) = delta { shift(l, d); diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 8b75dcbc8..eb0f610f9 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + // A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by + // its painted bounds at the current time instead, and anchor at the bounds' centre — content + // bounds live in layer space, where a shape is centred on the origin and text sits above its + // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the + // source size. + let ectx = effectcraft_render::EvalCtx { + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + ..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t) + }; + let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); + let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id)) + .map(|l| { + let (w, h) = effectcraft_render::source_size(&s.project, l); + if fitting && (w == 0 || h == 0) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) { + Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), + None => (l.id, [0.0; 2], [0.0; 2]), + } + } else { + (l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0]) + } + }) + .collect(); s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { + for (lid, size, anchor) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); + let (w, h) = (size[0], size[1]); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { @@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; + // A tiny shape's bounds can overflow the scale; refuse rather than write infinity. + if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() { + return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0))); + } let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index 33d17bb4f..ec8a1dfc3 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result { fn track_matte_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?; let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string(); let kind = match op.as_str() { "alpha" => Some(MatteKind::Alpha), diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..368e280c6 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); } // What each layer shows now (honouring an existing remap) — evaluated before the edit. - let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: cti, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers .iter() diff --git a/crates/engine/src/cmd/link.rs b/crates/engine/src/cmd/link.rs index 2eeb7c5a0..66145f17e 100644 --- a/crates/engine/src/cmd/link.rs +++ b/crates/engine/src/cmd/link.rs @@ -102,11 +102,39 @@ fn quote(s: &str) -> String { /// The AE expression reference to property `uid` of `target` as seen from a property of `from`. pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option { + reference_with(comp, from, target, uid, compact, false) +} + +/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an +/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The +/// effect keeps its name. An index is the property's place among its group's children, as an +/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place. +pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option { let chain = target.props.node_chain(uid)?; + // Each node's one-based index among its group's children, and whether it lies inside an + // effect (below the effect itself): those are selected by index with `by_index`. + let mut parent = &target.props; + let mut depth_in_effects: Option = None; + let mut by_number: Vec> = vec![]; + for n in &chain { + let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1); + by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1))); + depth_in_effects = match depth_in_effects { + Some(d) => Some(d + 1), + None => (n.match_id() == "effects").then_some(0), + }; + if let Node::Group(g) = n { + parent = g; + } + } + let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() { + Some(at) => format!("({at})"), + None => format!("({})", quote(label)), + }; let mut parts: Vec = vec![]; let mut pending: Option<&str> = None; let mut in_effect = false; - for n in chain { + for (i, n) in chain.iter().copied().enumerate() { if let Some(f) = pending.take() { parts.push(format!("{f}({})", quote(n.name()))); in_effect |= f == "effect"; @@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com (Node::Group(_), "transform") if !in_effect => parts.push("transform".into()), (Node::Group(_), "text") if !in_effect => parts.push("text".into()), (Node::Prop(_), "sourceText") => parts.push("sourceText".into()), - _ if in_effect => parts.push(format!("({})", quote(n.name()))), + _ if in_effect => parts.push(selector(i, n.name())), _ => parts.push(camel(n.name())), } } @@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com let _ = comp; if !compact { // Match names: language-independent, like After Effects with Compact English off. - let chain = target.props.node_chain(uid)?; let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) }; - for n in chain { - out.push_str(&format!("({})", quote(n.match_id()))); + for (i, n) in chain.iter().copied().enumerate() { + out.push_str(&selector(i, n.match_id())); } return Some(out); } @@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let from = comp.layer(lid).ok_or(EngineError::NoComp)?; let target = comp.layer(tl).ok_or(EngineError::NoComp)?; - let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let by_index = b_p(p, "byIndex").unwrap_or(false); + let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index) + .ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); // Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects. @@ -206,7 +235,7 @@ pub fn specs() -> Vec { "Pick Whip (Link Property)", [], None, - "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)", + "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)", has_layers, pick_whip ), diff --git a/crates/engine/src/cmd/markers.rs b/crates/engine/src/cmd/markers.rs index 4c979619e..3530eaa4b 100644 --- a/crates/engine/src/cmd/markers.rs +++ b/crates/engine/src/cmd/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { if l.markers_locked { continue; } + // One set of the times already on the layer: a file with thousands of markers must not + // scan the layer's list for each of them. + let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect(); for m in ms { - if !l.markers.iter().any(|x| x.time == m.time) { + if times.insert(m.time) { l.markers.push(m.clone()); n += 1; } @@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/engine/src/cmd/mask_interp.rs b/crates/engine/src/cmd/mask_interp.rs index 982468c8e..e2b53f8a4 100644 --- a/crates/engine/src/cmd/mask_interp.rs +++ b/crates/engine/src/cmd/mask_interp.rs @@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result { clip.push(crate::KeyClip { layer: lid, path, keys }); } s.state.key_clipboard = clip; + s.state.keyframe_text = None; s.state.clip_is_keys = true; // The same data as text (tab-separated, one row per frame) for other applications. let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime"); diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index bb74ee77c..93d71cd67 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Result { let y = v[1] * scale[1]; [x * c - y * sn, x * sn + y * c] }; - let n = s.edit("Free Transform Points", None, |proj, _| { + let n = s.edit("Free Transform Points", merge_p(p), |proj, _| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; @@ -542,7 +542,7 @@ pub fn specs() -> Vec { "Free Transform Points", ["Layer", "Mask and Shape Path"], None, - "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}", + "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}", has_layers, free_transform ), diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..ab2f0a1e2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res let fd = comp.frame_duration(); let mut t = layer.in_point; while t < layer.out_point { - let ctx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp: &comp, + time: t, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index 358275112..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -230,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, logConversion? (Cineon Settings of DPX and Cineon proxies, as file.interpretFootage), cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff?}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 320e1d415..1d080e773 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 1cda510e0..00e078372 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { } /// The `format` parameter's values. -const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; +const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff"; /// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`. fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { @@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { @@ -1017,7 +1017,7 @@ pub fn specs() -> Vec { "Add to Render Queue", ["Composition"], ["Cmd+M", "Cmd+Shift+/"], - "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", + "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add ), @@ -1037,7 +1037,7 @@ pub fn specs() -> Vec { "Output Module Settings...", [], None, - "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", + "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", has_items, set_output_module ), diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..5b17fcffe 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -48,7 +48,16 @@ impl Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { - EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + EvalCtx { + project: proj, + comp_id: cid, + comp, + time: self.time, + expr: self.expr.as_deref(), + footage: Some(self.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + } } } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 635429d6b..f4162043d 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -5,7 +5,8 @@ //! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts //! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels -//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of +//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -22,6 +23,11 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. +const PANELS_FOLDER: &str = "ScriptUI Panels"; +/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in +/// After Effects): never listed as File ▸ Scripts submenus. +const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"]; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects @@ -111,7 +117,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[ /// A script File ▸ Scripts (or the Window menu, for panels) offers. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ScriptEntry { - /// File name (`Rename Layers.jsx`): what the menus show and commands take. + /// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take. pub name: String, /// In the ScriptUI Panels folder: opens as a dockable panel. pub panel: bool, @@ -119,16 +125,23 @@ pub struct ScriptEntry { pub source: &'static str, } +/// Whether `name` ends in `.jsx` or `.js` (any letter case). fn is_script(name: &str) -> bool { let l = name.to_ascii_lowercase(); l.ends_with(".jsx") || l.ends_with(".js") } -/// Whether `name` can name an installed script: a bare file name, never a path (names are joined -/// onto the Scripts folders of the settings store, which takes them as relative paths), and on -/// Windows none of the names [`windows_unsafe_name`] lists. +/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a +/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI +/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of +/// the settings store, which takes them as relative paths), and on Windows none of the names +/// [`windows_unsafe_name`] lists. fn is_script_name(name: &str) -> bool { - is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name)) + let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n)); + match name.split_once('/') { + Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)), + None => part(name), + } } /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. @@ -155,8 +168,13 @@ pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { - for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) { - v.push(ScriptEntry { name: n, panel, source: "installed" }); + // The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`. + let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/"))); + for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) { + let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}")); + for n in names.filter(|n| is_script(n) && is_script_name(n)) { + v.push(ScriptEntry { name: n, panel, source: "installed" }); + } } } } @@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result { Ok(json!(scripts(s))) } +/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts +/// folders. fn uninstall(s: &mut Session, p: &Value) -> Result { let c = "file.uninstallScript"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?; if !is_script(name) || !is_script_name(name) { return Err(bad(c, format!("`{name}` is not a script file name"))); } + // A subfolder can be a link to files outside the settings folder (an extension's own + // folder), so only what Install puts at the top is removed, never a file through a folder. + if name.contains('/') { + return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)"))); + } let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { @@ -316,13 +341,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); - let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); + let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f); + let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) { let id = w.id; s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); return Ok(json!({"window": id, "open": true})); } - let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; + // Only panels: a script opened as one would be remembered, then forgotten at the next launch. + let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?; let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id); if let Some(id) = id { @@ -338,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result { Ok(json!({"window": id, "result": out})) } +/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings. pub fn specs() -> Vec { vec![ cmd!( @@ -358,14 +386,22 @@ pub fn specs() -> Vec { always, |s, p| install(s, p, true) ), - cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), - crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list), + cmd!( + "file.uninstallScript", + "Uninstall Script", + [], + None, + "{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}", + always, + uninstall + ), + crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list), cmd!( "window.scriptPanel", "ScriptUI Panel", [], None, - "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel", + "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel", always, open_panel ), @@ -504,13 +540,15 @@ mod tests { } } + /// Bundled and ordinary script file names are accepted; paths are not. #[test] fn script_names_are_bare_file_names() { let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"]; for name in BUNDLED.iter().map(|b| b.0).chain(more) { assert!(is_script(name) && is_script_name(name), "{name}"); } - for name in PATHS { + // `a/b.jsx` is a script in a folder (see below). + for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") { assert!(!is_script_name(name), "{name:?}"); } for name in WINDOWS_ONLY { @@ -528,6 +566,141 @@ mod tests { } } + /// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not. + #[test] + fn script_names_may_have_one_folder() { + for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] { + assert!(is_script_name(name), "{name:?}"); + } + // Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`. + let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"]; + for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) { + assert!(!is_script_name(name), "{name:?}"); + } + for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] { + assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}"); + } + } + + /// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`. + fn put(root: &std::path::Path, rel: &str, code: &str) { + let p = root.join("settings").join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, code).unwrap(); + } + + /// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level. + #[test] + fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() { + let (mut s, root) = session_in_tmp("folders"); + put(&root, "Scripts/Tools/Hello.jsx", "hello"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate"); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top"); + // Two folders deep: neither listed nor run. + put(&root, "Scripts/a/b/c.jsx", "too deep"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep"); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let installed: Vec<(&str, bool)> = + list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect(); + assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]); + assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello"); + // A panel in a folder is titled after its file. + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let ran = r["result"]["ran"].as_str().unwrap_or_default(); + assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + // A script that is not a panel doesn't open as one. + for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] { + assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}"); + } + for name in [ + "../x.jsx", + "a/b/c.jsx", + "a\\b.jsx", + "Motion Kit/sub/Deep.jsx", + "ScriptUI Panels/Top.jsx", + "Startup/Run.jsx", + "shutdown/Run.jsx", + "Tools/../../../outside.jsx", + ] { + assert_eq!(script_code(&s, name, None), None, "{name:?}"); + for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] { + let r = s.execute(c, json!({"name": name})); + assert!(bad_params(&r), "{c} `{name}`: {r:?}"); + } + } + assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + assert!(root.join("outside.jsx").is_file()); + // Uninstall removes only scripts at the top; one in a folder is deleted there by hand. + for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] { + assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}"); + } + assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + let _ = std::fs::remove_dir_all(&root); + } + + /// A subfolder can be a link to an extension's own folder, outside the settings folder: its + /// scripts are listed and run from there, but uninstall never deletes through it. + #[cfg(unix)] + #[test] + fn uninstall_never_deletes_through_a_linked_subfolder() { + let (mut s, root) = session_in_tmp("linked"); + std::fs::create_dir_all(root.join("extension")).unwrap(); + std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap(); + std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"})); + assert!(bad_params(&r), "{r:?}"); + assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder + /// sorts; the entries outside folders stay as they were. + #[test] + fn a_subfolder_is_a_submenu() { + use crate::menus::{MenuNode, dynamic, dynamic_nodes}; + let (s, root) = session_in_tmp("submenus"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", ""); + put(&root, "Scripts/Tools/Hello.jsx", ""); + let cx = Default::default(); + let item = + |label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null}); + let menus = [ + ( + "scriptPanels", + json!({"type": "submenu", "label": "Motion Kit", "children": [ + item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"), + item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"), + ]}), + ), + ("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})), + ]; + for (menu, folder) in menus { + let (nodes, _) = dynamic_nodes(&s, menu, &cx); + let (entries, _) = dynamic(&s, menu, &cx); + let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect(); + assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::>(), "{menu}"); + let folders: Vec = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect(); + assert_eq!(folders, [folder], "{menu}"); + } + let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx); + let labels: Vec<&str> = panels + .iter() + .filter_map(|n| match n { + MenuNode::Item(e) => Some(e.label.as_str()), + MenuNode::Submenu { label, .. } => Some(label), + _ => None, + }) + .collect(); + assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn uninstall_takes_a_script_name_not_a_path() { let (mut s, root) = session_in_tmp("uninstall"); diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index e1f3607b1..3323d61d6 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] { [c[0] as f64, c[1] as f64, c[2] as f64, 1.0] } -/// The Rounded Rectangle tool's corner roundness for `size`. -fn roundness(size: [f64; 2]) -> f64 { - size[0].min(size[1]) * 0.15 +/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects +/// (it does not grow with the drawn size). +fn roundness() -> f64 { + 20.0 } /// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius). fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { match kind { "star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)), - "polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)), + "polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)), _ => None, } } @@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option { let (path, gname) = match kind { "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), - "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), k => { let (star, points, outer, inner) = polystar_of(k, size)?; @@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), - "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)), + "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()), k => { let (star, points, outer, inner) = polystar_of(k, size)?; effectcraft_path::polystar(effectcraft_path::Polystar { diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 92ab23f17..296dc4c37 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index 0d9b25aec..cdb650486 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -71,7 +71,8 @@ pub struct TextEdit { /// Character panel changes made with only a caret: the style the next typed text takes. #[serde(default)] pub pending: Option, - /// The layer was made by a Type tool click; leaving it empty deletes it. + /// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this + /// only records how the edit began. #[serde(default)] pub created: bool, /// Line position kept by repeated up / down moves (layout space). @@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). +/// `text.endEdit`: stop editing. +/// +/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in +/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a +/// mistake to clean up. Deleting it took away something they could still type into. pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { - let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; - if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - s.edit("Delete Empty Text Layer", None, |proj, st| { - if let Some(c) = proj.comp_mut(cid) { - c.layers.retain(|l| l.id != e.layer); - } - st.selected_layers.retain(|l| *l != e.layer); - Ok(()) - })?; + if s.state.text_edit.take().is_none() { + return Ok(Value::Null); } s.bump(); Ok(Value::Null) diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..1cf470ebe 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 954a7d24e..c11f575b0 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -86,6 +86,10 @@ pub struct EditorState { /// Paste sets the same properties of the selected layers. #[serde(skip)] pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// The copied keyframes and values as After Effects keyframe text (one layer's Transform + /// properties), for the system clipboard. + #[serde(skip)] + pub keyframe_text: Option, /// Copied effect instances, with comp-time key offsets from the earliest copied key. /// Paste adds them to the selected layers at the destination comp's CTI. #[serde(skip)] diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 75fd0f936..267985e37 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { nf.codec = probed.codec; nf.has_video = probed.has_video; nf.has_audio = probed.has_audio; + // The markers the file carries now (a WAV's cue points), for Update Markers From Source. + nf.markers = probed.markers; if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { nf.duration = probed.duration; // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. diff --git a/crates/engine/src/footage/sequence.rs b/crates/engine/src/footage/sequence.rs index 46dca154c..fc2dab2b0 100644 --- a/crates/engine/src/footage/sequence.rs +++ b/crates/engine/src/footage/sequence.rs @@ -45,14 +45,13 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off - /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered - /// still brings its run, as it always has. + /// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports + /// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018). pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); - let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. - SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } + SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } } } diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 500308b72..0b15baaf5 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -645,6 +645,21 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("footage").to_string() } +/// A template entry's name, made safe to write inside a folder: a template is a file like any +/// other and can name its entries anything. Nothing a platform reads as a separator, a drive or +/// the folder above survives, and a name that leaves nothing usable becomes `footage` (#2123). +fn safe_file_name(name: &str) -> String { + let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); + // `.` and `..`, and names of dots and spaces alone (Windows drops the trailing ones). + if name.trim_matches(['.', ' ']).is_empty() { "footage".to_string() } else { name } +} + +/// What two entry names must differ by to be sure of two files: a filesystem may ignore case, and +/// Win32 ignores trailing dots and spaces. +fn same_file_key(name: &str) -> String { + name.trim_end_matches(['.', ' ']).to_lowercase() +} + /// What [`embed_footage`] did. #[derive(Default)] struct Embedded { @@ -738,9 +753,21 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec) let mut used = std::collections::HashSet::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { // `media//` → one folder; a name another item took gets the item's id. - let file = base_name(k); - let item = k.trim_start_matches("media/").split('/').next().unwrap_or("0"); - let name = if used.insert(file.clone()) { file } else { format!("{item}-{file}") }; + let file = safe_file_name(&base_name(k)); + // The item's id, and only that: it is written as one ([`embed_footage`]), but a template + // from elsewhere can hold anything there, and `..\` in it walks out of `dir` on Windows. + let item = k.trim_start_matches("media/").split('/').next().filter(|i| i.bytes().all(|b| b.is_ascii_digit())).unwrap_or("0"); + // A name another entry has taken gets the item's id, and a number if that is taken too: + // the names an entry can ask for are not distinct, and two files must not become one. + // They are compared the way a filesystem might: `CLIP.png` is `clip.png` on a + // case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing + // dot or space, so `a.png.` is `a.png` there. The name written stays as it is. + let mut name = file.clone(); + let mut n = 0usize; + while !used.insert(same_file_key(&name)) { + n += 1; + name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; + } let out = format!("{dir}/{name}"); s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; if let Some(imp) = &s.importer { @@ -970,6 +997,57 @@ mod tests { assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]); } + /// #2123: a template holds its footage as `media//` entries, but a template + /// from anywhere else can name them anything — and `..\\`, which only this platform reads as + /// a plain character, is a separator on Windows, so a second entry with a base name already + /// taken wrote `

/..\\..\\..\\Startup\\x-evil.bat`. Whatever an entry is called, the file + /// lands in the footage folder. + #[test] + fn extracted_footage_stays_in_its_folder() { + let s = Session::default(); + let mut proj = effectcraft_project::Project::default(); + let dir = std::env::temp_dir().join(format!("ec-templates-media-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ds = dir.to_string_lossy().into_owned(); + let entry = |k: &str| (k.to_string(), b"x".to_vec()); + let entries = [ + entry("media/0/clip.mp4"), + // The base name is taken, so this one's "item id" is used — and it is not one. + entry("media/..\\..\\..\\Startup\\x/clip.mp4"), + entry("media/1/C:evil.bat"), + // Three that collide once the first is made safe: each still gets its own file. + entry("media/1/a:b.png"), + entry("media/2/a_b.png"), + entry("media/3/2-a_b.png"), + // The same file on a case-insensitive volume, and on Windows, which drops the dot. + entry("media/4/clip.png"), + entry("media/5/CLIP.png"), + entry("media/6/a.png"), + entry("media/7/a.png."), + ]; + let written = extract_footage(&s, &mut proj, &entries, &ds).unwrap(); + assert_eq!(written.len(), entries.len()); + for w in &written { + let name = w.strip_prefix(&format!("{ds}/")).unwrap_or_else(|| panic!("{w} is outside {ds}")); + assert!(!name.contains(['/', '\\', ':']), "{w}"); + assert!(!name.trim_matches(['.', ' ']).is_empty(), "{w}"); + } + // One file each, all of them in the folder, none overwritten by the next. + assert_eq!(written.iter().collect::>().len(), entries.len(), "{written:?}"); + assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), entries.len()); + let _ = std::fs::remove_dir_all(&dir); + // And the names that would leave nothing to write. + for n in ["..", ".", " .. ", "...", ""] { + assert_eq!(safe_file_name(n), "footage", "{n:?}"); + } + assert_eq!(safe_file_name("clip.mp4"), "clip.mp4"); + assert_eq!(safe_file_name(".hidden.mp4"), ".hidden.mp4"); + assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d"); + // Names a filesystem may not tell apart (only the case half of this shows on macOS). + assert_eq!(same_file_key("A.PNG. "), same_file_key("a.png")); + assert_ne!(same_file_key("a.png"), same_file_key("b.png")); + } + #[test] fn create_opens_an_untitled_copy() { let mut s = Session::default(); diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index ec4cff579..3b166b83e 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -1,3 +1,5 @@ +pub(crate) mod keyframe_text; +pub(crate) mod keyframe_text_effects; pub mod psd_import; pub mod vector; pub mod xml_project; diff --git a/crates/engine/src/interchange/keyframe_text.rs b/crates/engine/src/interchange/keyframe_text.rs new file mode 100644 index 000000000..5f8ba9441 --- /dev/null +++ b/crates/engine/src/interchange/keyframe_text.rs @@ -0,0 +1,348 @@ +//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other +//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After +//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame +//! number in comp time, then the value) and static ones as a single row with no frame. Easing +//! and tangents aren't part of the format; EffectCraft's own paste keeps them. + +use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; + +use crate::{KeyRef, keyframe_text_effects}; + +/// Transform properties with the unit their columns are labelled with and, for vectors, the +/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit. +const TRANSFORM: &[(&str, &str, Option)] = &[ + ("poi", "pixels", Some(0.0)), + ("anchor", "pixels", Some(0.0)), + ("position", "pixels", Some(0.0)), + ("positionX", "", None), + ("positionY", "", None), + ("positionZ", "", None), + ("scale", "percent", Some(100.0)), + ("orientation", "degrees", None), + ("rotationX", "degrees", None), + ("rotationY", "degrees", None), + ("rotation", "degrees", None), + ("opacity", "percent", None), +]; + +/// How a copied property is written: its block's header line, column labels and values. +struct Channel { + header: String, + labels: Vec, + values: Values, +} + +/// How a property's value becomes the numbers in a row. +#[derive(Clone, Copy)] +enum Values { + Plain, + /// A 2D layer's Transform vector: the third component is the one the layer shows (it may + /// keep another from pasted 3D keys). + Flat(f64), + /// RGBA 0–1 as alpha, red, green, blue 0–255. + Argb, + /// A popup's 0-based choice as After Effects' 1-based one. + OneBased, + Scale(f64), +} + +impl Values { + fn row(self, mut v: Vec) -> Vec { + match (self, v.as_slice()) { + (Values::Flat(z), _) => { + if let Some(c) = v.get_mut(2) { + *c = z; + } + v + } + (Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(), + (Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(), + (Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(), + _ => v, + } + } +} + +/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come +/// from one layer and at least one property can be written. +pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { + let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; + if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { + return None; + } + let layer = comp.layer(lid)?; + let fps = comp.frame_rate.as_f64(); + let mut blocks = String::new(); + layer.props.walk("", &mut |_, pr| { + let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + let rows: Vec<(String, Vec)> = if !times.is_empty() { + let frame = |t| num(layer.comp_time(t).seconds() * fps); + times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect() + } else if statics.iter().any(|s| s.1 == pr.uid) { + vec![(String::new(), pr.value.components())] + } else { + return; + }; + let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return }; + if rows.iter().any(|(_, v)| v.is_empty()) { + return; + } + blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t"))); + for (frame, values) in rows { + let values: Vec = ch.values.row(values).into_iter().map(num).collect(); + blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); + } + blocks.push('\n'); + }); + if blocks.is_empty() { + return None; + } + // After Effects writes a text layer's source as 1 × 1. Other layers without a source of + // their own (shapes, cameras) take the comp's size, and all of them its pixel aspect. + let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) { + (LayerSource::Text, _) => (1, 1), + (_, (0, 0)) => (comp.width, comp.height), + (_, size) => size, + }; + let par = match layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.pixel_aspect, + Some(ItemKind::Solid(s)) => s.pixel_aspect, + Some(ItemKind::Comp(c)) => c.pixel_aspect, + _ => comp.pixel_aspect, + }, + _ => comp.pixel_aspect, + }; + // The layout of current After Effects: a `Layer` line before the layer's properties (parsers + // such as ae-keyframes reject properties without one), and a tab after every column. + Some(format!( + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n", + num(fps), + num(par), + num(comp.pixel_aspect) + )) +} + +/// How the layer's property `pr` is written, by where it sits in the layer: `None` for +/// properties the text doesn't carry. +fn channel(layer: &Layer, pr: &Property) -> Option { + let chain = layer.props.node_chain(pr.uid)?; + match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) { + None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), + Some("transform") => transform(layer, pr), + Some("effects") => effect(&chain, pr), + Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr), + _ => None, + } +} + +/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind +/// and place (`Mask #2`, `Group #1`), not by name. +const INSTANCES: &[(&str, &str)] = &[ + ("mask", "Mask"), + ("animator", "Animator"), + ("rangeSelector", "Range Selector"), + ("wigglySelector", "Wiggly Selector"), + ("expressionSelector", "Expression Selector"), + ("group", "Group"), + ("rect", "Rectangle Path"), + ("ellipse", "Ellipse Path"), + ("star", "Polystar Path"), + ("path", "Path"), + ("fill", "Fill"), + ("stroke", "Stroke"), + ("gfill", "Gradient Fill"), + ("gstroke", "Gradient Stroke"), + ("trim", "Trim Paths"), + ("repeater", "Repeater"), + ("round", "Round Corners"), + ("offset", "Offset Paths"), + ("pucker", "Pucker & Bloat"), + ("twist", "Twist"), + ("zigzag", "Zig Zag"), + ("wiggle", "Wiggle Paths"), + ("merge", "Merge Paths"), +]; + +/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's +/// name, then each group down to the property. Instances go by kind and place, and a text +/// selector's own properties carry their place too (`Start #1`). +fn nested(chain: &[&Node], pr: &Property) -> Option { + let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); + let parent = groups.last()?; + let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?; + let mut header = groups.first()?.name.clone(); + for pair in groups.windows(2) { + let [outer, g] = pair else { continue }; + match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) { + Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)), + None => header.push_str(&format!("\t{}", g.name)), + } + } + if parent.match_id.ends_with("Selector") { + header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?)); + } else { + header.push_str(&format!("\t{}", pr.name)); + } + Some(Channel { header, labels, values }) +} + +/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, +/// then the property's name and After Effects index. Properties in twirl-down groups are +/// written without their group, as After Effects does. +fn effect(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; + let GroupKind::Effect { effect } = &fx.kind else { return None }; + let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); + let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect(); + let (labels, values) = columns(effect, &path, pr, true)?; + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?); + Some(Channel { header, labels, values }) +} + +/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a +/// twirl-down group takes an index where it opens and another where it closes. The hidden +/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects +/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it. +fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option { + fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool { + for c in &g.children { + match c { + Node::Prop(p) if is_layer_source(g, p) => {} + Node::Prop(p) => { + let path = format!("{prefix}{}", p.match_id); + *n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2); + if p.uid == uid { + return true; + } + } + Node::Group(sub) => { + *n += 1; + if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) { + return true; + } + *n += 1; + } + } + } + false + } + let mut n = 1; + count(effect, fx, "", uid, &mut n).then_some(n) +} + +fn is_layer_source(g: &PropGroup, p: &Property) -> bool { + p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id) +} + +/// 1-based position of a child in its group. +fn place(group: &PropGroup, uid: Uid) -> Option { + group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) +} + +/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]). +#[derive(Clone, Copy)] +enum Exception { + /// Not written. + Skip, + Unit(&'static str), + Values(Values), +} + +/// Properties After Effects writes differently from what their editor suggests, by the effect +/// or group they're directly in and their match id. +const EXCEPTIONS: &[(&str, &str, Exception)] = &[ + // Path Options' Path picks a mask, which After Effects doesn't write. + ("pathOptions", "path", Exception::Skip), + ("mask", "feather", Exception::Unit("pixels")), + ("mask", "expansion", Exception::Unit("pixels")), + ("cameraOptions", "zoom", Exception::Unit("pixels")), + ("cameraOptions", "focusDistance", Exception::Unit("pixels")), + ("cameraOptions", "aperture", Exception::Unit("pixels")), + ("cameraOptions", "highlightGain", Exception::Unit("")), + ("cameraOptions", "highlightSaturation", Exception::Unit("")), + // After Effects keeps the threshold as 0–1. + ("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))), + ("lightOptions", "shadowDiffusion", Exception::Unit("pixels")), + // Off, On, Only: written from 0. + ("materialOptions", "castsShadows", Exception::Values(Values::Plain)), + ("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)), +]; + +/// Column labels and values for a property in `scope`, the effect (by id) or group (by match +/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`] +/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise. +fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr, in_effect)?; + let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2); + let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2)); + Some(match exception.or_else(effect_unit) { + None => (by_editor, values), + Some(Exception::Skip) => return None, + Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values), + Some(Exception::Values(values)) => (by_editor, values), + }) +} + +/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100 +/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts), +/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists +/// the effect parameters After Effects labels otherwise. +fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let dims = pr.value.dims(); + Some(match &pr.ui { + ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), + ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), + ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain), + ParamUi::Percent => (labels("percent", dims), Values::Plain), + ParamUi::Angle => (labels("degrees", dims), Values::Plain), + ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), + ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain), + _ => return None, + }) +} + +fn transform(layer: &Layer, pr: &Property) -> Option { + let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { + return None; + } + // After Effects labels only the first of Orientation's three columns, and names Z Rotation + // plain Rotation (as AE2Blend expects). + let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) }; + let values = match z { + Some(z) if !layer.is_3d() => Values::Flat(*z), + _ => Values::Plain, + }; + Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) +} + +/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After +/// Effects writes a shape's Size). +fn labels(unit: &str, dims: usize) -> Vec { + match dims { + 0 => vec![], + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), + } +} + +/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at +/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z +/// Position. +fn active(layer: &Layer, id: &str) -> bool { + match id { + "poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest, + "position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()), + _ => true, + } +} + +/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`). +fn num(v: f64) -> String { + let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} diff --git a/crates/engine/src/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs new file mode 100644 index 000000000..83f5b6c20 --- /dev/null +++ b/crates/engine/src/interchange/keyframe_text_effects.rs @@ -0,0 +1,386 @@ +//! After Effects' index and unit for effect parameters where they differ from what +//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text. +//! After Effects keeps an effect's parameter indices for good (projects store them), so they +//! hold for later versions. Parameters After Effects lacks, or names differently and keeps +//! elsewhere, aren't listed. + +/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After +/// Effects' index for it; the parameters after it count on from there. After Effects numbers +/// legacy parameters it hides, so its indices skip ahead. +pub(crate) const INDICES: &[(&str, &str, usize)] = &[ + ("ec.3d.channelextract", "clampOutput", 6), + ("ec.3d.channelextract", "antialias", 5), + ("ec.3d.extractor", "blackPoint", 4), + ("ec.audio.parametriceq", "band1Enable", 3), + ("ec.blur.radial", "amount", 3), + ("ec.color.cccolorneutralizer", "midtonesUnbalance", 8), + ("ec.color.cccolorneutralizer", "highlightsUnbalance", 14), + ("ec.color.cccolorneutralizer", "pinning", 20), + ("ec.color.cckernel", "k1", 3), + ("ec.color.cckernel", "k4", 8), + ("ec.color.cckernel", "k7", 13), + ("ec.color.cckernel", "scale", 17), + ("ec.color.cckernel", "absolute", 18), + ("ec.color.colorstabilizer", "stabilize", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.levels", "inBlack", 4), + ("ec.color.levelsic", "rgb/rgbInBlack", 5), + ("ec.color.lumetri", "highDynamicRange", 3), + ("ec.color.lumetri", "basicCorrection/inputLut", 8), + ("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12), + ("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22), + ("ec.color.lumetri", "basicCorrection/saturation", 26), + ("ec.color.lumetri", "creative/look", 32), + ("ec.color.lumetri", "creative/lookIntensity", 33), + ("ec.color.lumetri", "creative/adjustments/tintBalance", 40), + ("ec.color.lumetri", "hslSecondary/key/showMask", 83), + ("ec.color.lumetri", "hslSecondary/refine/denoise", 90), + ("ec.color.lumetri", "hslSecondary/correction/temperature", 96), + ("ec.color.lumetri", "vignette/vignetteAmount", 105), + ("ec.color.selectivecolor", "details/reds/redsCyan", 10), + ("ec.color.videolimiter", "compressionBeforeClipping", 3), + ("ec.distort.ccflomotion", "knot1", 3), + ("ec.distort.ccpowerpin", "expandTop", 9), + ("ec.distort.upscale", "scale", 4), + ("ec.distort.puppet", "refinement", 3), + ("ec.control.stereo3d", "configuration", 3), + ("ec.control.stereo3d", "convergenceOf", 8), + ("ec.generate.advancedlightning", "turbulence", 17), + ("ec.generate.advancedlightning", "alphaObstacle", 16), + ("ec.generate.advancedlightning", "compositeOnOriginal", 21), + ("ec.generate.ccgluegun", "style", 9), + ("ec.generate.cclightburst", "setColor", 7), + ("ec.generate.cclightrays", "colorFromSource", 8), + ("ec.generate.cclightrays", "color", 10), + ("ec.generate.circle", "feather/feather", 7), + ("ec.generate.paintbucket", "color", 9), + ("ec.vr.blur", "frameLayout", 3), + ("ec.vr.blur", "blurriness", 6), + ("ec.vr.chromaticaberrations", "frameLayout", 3), + ("ec.vr.chromaticaberrations", "aberrationRed", 7), + ("ec.vr.colorgradients", "frameLayout", 3), + ("ec.vr.colorgradients", "point1", 10), + ("ec.vr.colorgradients", "point2", 12), + ("ec.vr.colorgradients", "point3", 14), + ("ec.vr.colorgradients", "point4", 16), + ("ec.vr.colorgradients", "point5", 18), + ("ec.vr.colorgradients", "blend", 7), + ("ec.vr.colorgradients", "opacity", 27), + ("ec.vr.denoise", "frameLayout", 3), + ("ec.vr.denoise", "noiseType", 6), + ("ec.vr.digitalglitch", "frameLayout", 3), + ("ec.vr.digitalglitch", "masterAmplitude", 9), + ("ec.vr.digitalglitch", "distortionRate", 16), + ("ec.vr.digitalglitch", "distortionComplexity", 15), + ("ec.vr.digitalglitch", "distortionEvolution", 17), + ("ec.vr.digitalglitch", "colorDistortion", 11), + ("ec.vr.digitalglitch", "randomSeed", 39), + ("ec.vr.fractalnoise", "frameLayout", 3), + ("ec.vr.fractalnoise", "fractalType", 6), + ("ec.vr.fractalnoise", "randomSeed", 25), + ("ec.vr.glow", "frameLayout", 3), + ("ec.vr.glow", "luminanceThreshold", 6), + ("ec.vr.planetosphere", "frameLayout", 3), + ("ec.vr.planetosphere", "scale", 6), + ("ec.vr.planetosphere", "feather", 8), + ("ec.vr.planetosphere", "tilt", 15), + ("ec.vr.rotatesphere", "frameLayout", 3), + ("ec.vr.rotatesphere", "invertRotation", 9), + ("ec.vr.rotatesphere", "tilt", 6), + ("ec.vr.sharpen", "frameLayout", 3), + ("ec.vr.sharpen", "sharpenAmount", 6), + ("ec.key.colordifference", "view", 3), + ("ec.key.colorrange", "fuzziness", 3), + ("ec.key.extract", "channel", 3), + ("ec.key.linearcolor", "view", 3), + ("ec.matte.refinehard", "feather", 2), + ("ec.matte.refinehard", "reduceChatter", 5), + ("ec.matte.refinehard", "decontaminationAmount", 14), + ("ec.matte.refinesoft", "reduceChatter", 10), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", 13), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22), + ("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27), + ("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29), + ("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8), + ("ec.obsolete.basictext", "position", 2), + ("ec.key.spill", "suppression", 3), + ("ec.perspective.cameratracker", "shotType", 4), + ("ec.perspective.cameratracker", "advanced/solveMethod", 12), + ("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17), + ("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18), + ("ec.perspective.cccylinder", "rotationX", 9), + ("ec.perspective.cccylinder", "render", 13), + ("ec.perspective.cccylinder", "lightIntensity", 15), + ("ec.perspective.cccylinder", "ambient", 21), + ("ec.sim.ccdrizzle", "light/lightIntensity", 10), + ("ec.sim.ccdrizzle", "light/lightHeight", 13), + ("ec.sim.ccdrizzle", "light/lightDirection", 15), + ("ec.sim.ccmrmercury", "blobBirthSize", 15), + ("ec.sim.ccmrmercury", "light/lightIntensity", 19), + ("ec.sim.ccmrmercury", "light/lightHeight", 22), + ("ec.sim.ccmrmercury", "light/lightDirection", 24), + ("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24), + ("ec.sim.ccparticlesystems2", "particle/birthColor", 27), + ("ec.sim.ccrainfall", "transferMode", 16), + ("ec.sim.ccrainfall", "extras/randomSeed", 23), + ("ec.sim.ccsnowfall", "color", 19), + ("ec.sim.ccsnowfall", "transferMode", 26), + ("ec.sim.ccsnowfall", "extras/randomSeed", 32), + ("ec.stylize.ccblockload", "startCleared", 4), + ("ec.stylize.cchextile", "rotate", 6), + ("ec.stylize.glow", "colorLooping", 9), + ("ec.text.timecode", "timeUnits", 5), + ("ec.text.timecode", "textPosition", 9), + ("ec.utility.facemeasurements", "headOrientationX", 9), + ("ec.utility.facemeasurements", "rightEyebrowRaise", 20), + ("ec.utility.facemeasurements", "mouthOffsetX", 26), + ("ec.utility.facetrackpoints", "leftEyebrowInner", 10), + ("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12), + ("ec.utility.facetrackpoints", "leftEyebrowOuter", 14), + ("ec.utility.facetrackpoints", "rightEyebrowInner", 24), + ("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26), + ("ec.utility.facetrackpoints", "rightEyebrowOuter", 28), + ("ec.utility.facetrackpoints", "leftEyeInner", 15), + ("ec.utility.facetrackpoints", "leftEyeOuter", 21), + ("ec.utility.facetrackpoints", "leftPupil", 20), + ("ec.utility.facetrackpoints", "rightEyeInner", 29), + ("ec.utility.facetrackpoints", "rightEyeOuter", 35), + ("ec.utility.facetrackpoints", "rightPupil", 34), + ("ec.utility.facetrackpoints", "noseTip", 41), + ("ec.utility.facetrackpoints", "leftNostril", 44), + ("ec.utility.facetrackpoints", "rightNostril", 46), + ("ec.utility.facetrackpoints", "mouthLeft", 49), + ("ec.utility.facetrackpoints", "mouthRight", 55), + ("ec.utility.facetrackpoints", "mouthTop", 52), + ("ec.utility.facetrackpoints", "mouthBottom", 58), + ("ec.utility.facetrackpoints", "chin", 84), +]; + +/// Effect id, parameter path and the unit After Effects labels the parameter's columns with. +pub(crate) const UNITS: &[(&str, &str, &str)] = &[ + ("ec.3d.depthoffield", "focalBias", ""), + ("ec.3d.idmatte", "feather", ""), + ("ec.audio.flangechorus", "voiceSeparationTime", ""), + ("ec.audio.flangechorus", "voicePhaseChange", ""), + ("ec.audio.modulator", "modulationRate", ""), + ("ec.audio.parametriceq", "band1BoostCut", "percent"), + ("ec.audio.parametriceq", "band2BoostCut", "percent"), + ("ec.audio.parametriceq", "band3BoostCut", "percent"), + ("ec.audio.stereomixer", "leftLevel", "percent"), + ("ec.audio.stereomixer", "rightLevel", "percent"), + ("ec.audio.stereomixer", "leftPan", "percent"), + ("ec.audio.stereomixer", "rightPan", "percent"), + ("ec.blur.bilateral", "threshold", ""), + ("ec.blur.ccradial", "amount", "pixels"), + ("ec.blur.ccradialfast", "amount", ""), + ("ec.blur.camerashakedeblur", "strength", "percent"), + ("ec.blur.radial", "amount", ""), + ("ec.blur.smart", "radius", ""), + ("ec.blur.smart", "threshold", ""), + ("ec.color.autocolor", "blackClip", "percent"), + ("ec.color.autocolor", "whiteClip", "percent"), + ("ec.color.autocontrast", "blackClip", "percent"), + ("ec.color.autocontrast", "whiteClip", "percent"), + ("ec.color.autolevels", "blackClip", "percent"), + ("ec.color.autolevels", "whiteClip", "percent"), + ("ec.color.brightnesscontrast", "contrast", "pixels"), + ("ec.color.cccolorneutralizer", "pinning", ""), + ("ec.color.cccolorneutralizer", "blendWOriginal", ""), + ("ec.color.changecolor", "lightnessTransform", "pixels"), + ("ec.color.changecolor", "saturationTransform", "pixels"), + ("ec.color.colorbalance", "shadowRed", "pixels"), + ("ec.color.colorbalance", "shadowGreen", "pixels"), + ("ec.color.colorbalance", "shadowBlue", "pixels"), + ("ec.color.colorbalance", "midRed", "pixels"), + ("ec.color.colorbalance", "midGreen", "pixels"), + ("ec.color.colorbalance", "midBlue", "pixels"), + ("ec.color.colorbalance", "hiRed", "pixels"), + ("ec.color.colorbalance", "hiGreen", "pixels"), + ("ec.color.colorbalance", "hiBlue", "pixels"), + ("ec.color.colorbalancehls", "lightness", "pixels"), + ("ec.color.colorbalancehls", "saturation", "pixels"), + ("ec.color.colorstabilizer", "sampleSize", "percent"), + ("ec.color.gammapedestalgain", "blackStretch", "percent"), + ("ec.color.huesaturation", "saturation", "pixels"), + ("ec.color.huesaturation", "lightness", "pixels"), + ("ec.color.huesaturation", "colorizeSaturation", ""), + ("ec.color.huesaturation", "colorizeLightness", "pixels"), + ("ec.color.lumetri", "creative/adjustments/fadedFilm", ""), + ("ec.color.lumetri", "hslSecondary/refine/denoise", ""), + ("ec.color.lumetri", "hslSecondary/refine/blur", ""), + ("ec.color.lumetri", "vignette/vignetteMidpoint", ""), + ("ec.color.lumetri", "vignette/vignetteFeather", ""), + ("ec.color.psarbitrarymap", "phase", "degrees"), + ("ec.color.selectivecolor", "details/reds/redsCyan", "percent"), + ("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"), + ("ec.color.selectivecolor", "details/reds/redsYellow", "percent"), + ("ec.color.selectivecolor", "details/reds/redsBlack", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"), + ("ec.color.selectivecolor", "details/greens/greensCyan", "percent"), + ("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"), + ("ec.color.selectivecolor", "details/greens/greensYellow", "percent"), + ("ec.color.selectivecolor", "details/greens/greensBlack", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"), + ("ec.color.shadowhighlight", "shadowAmount", ""), + ("ec.color.shadowhighlight", "highlightAmount", ""), + ("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"), + ("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"), + ("ec.distort.bulge", "height", "pixels"), + ("ec.distort.bulge", "taperRadius", ""), + ("ec.distort.ccbendit", "bend", "pixels"), + ("ec.distort.ccbender", "amount", "pixels"), + ("ec.distort.ccgriddler", "tileSize", "percent"), + ("ec.distort.cctiler", "scale", "percent"), + ("ec.distort.upscale", "scale", "percent"), + ("ec.distort.magnify", "magnification", "pixels"), + ("ec.distort.magnify", "size", "percent"), + ("ec.distort.puppet", "refinement", ""), + ("ec.distort.ripple", "radius", ""), + ("ec.distort.ripple", "speed", "percent"), + ("ec.distort.transform", "opacity", ""), + ("ec.distort.turbulentdisplace", "complexity", "percent"), + ("ec.distort.twirl", "radius", ""), + ("ec.distort.wavewarp", "width", "percent"), + ("ec.distort.wavewarp", "speed", "pixels"), + ("ec.generate.fourcolor", "blend", "percent"), + ("ec.generate.advancedlightning", "coreSettings/coreRadius", ""), + ("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"), + ("ec.generate.advancedlightning", "alphaObstacle", "pixels"), + ("ec.generate.ccgluegun", "reflection", ""), + ("ec.generate.cclightburst", "rayLength", ""), + ("ec.generate.cclightsweep", "edgeThickness", ""), + ("ec.generate.ccthreads", "coverage", ""), + ("ec.generate.ccthreads", "shadowing", ""), + ("ec.generate.ccthreads", "texture", ""), + ("ec.generate.checkerboard", "width", "percent"), + ("ec.generate.checkerboard", "height", "percent"), + ("ec.generate.grid", "width", "percent"), + ("ec.generate.grid", "height", "percent"), + ("ec.generate.lensflare", "flareBrightness", "percent"), + ("ec.vr.chromaticaberrations", "falloff", ""), + ("ec.vr.denoise", "noiseLevel", ""), + ("ec.vr.digitalglitch", "colorDistortion", ""), + ("ec.vr.fractalnoise", "subSettings/subInfluence", ""), + ("ec.vr.glow", "luminanceThreshold", ""), + ("ec.vr.planetosphere", "feather", ""), + ("ec.vr.sharpen", "sharpenAmount", ""), + ("ec.key.advancedspill", "suppression", "percent"), + ("ec.key.advancedspill", "ultraSettings/tolerance", ""), + ("ec.key.advancedspill", "ultraSettings/desaturate", ""), + ("ec.key.advancedspill", "ultraSettings/spillRange", ""), + ("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""), + ("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""), + ("ec.key.ccsimplewireremoval", "thickness", ""), + ("ec.key.keycleaner", "additionalEdgeRadius", ""), + ("ec.key.keycleaner", "alphaContrast", "percent"), + ("ec.matte.refinehard", "feather", ""), + ("ec.matte.refinehard", "increaseDecontaminationRadius", ""), + ("ec.matte.refinesoft", "smooth", ""), + ("ec.matte.refinesoft", "feather", ""), + ("ec.matte.refinesoft", "shiftEdge", "percent"), + ("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/feather", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.simplechoker", "chokeMatte", "pixels"), + ("ec.noise.fractal", "transform/scale", "percent"), + ("ec.noise.fractal", "transform/scaleWidth", "percent"), + ("ec.noise.fractal", "transform/scaleHeight", "percent"), + ("ec.noise.fractal", "complexity", "percent"), + ("ec.noise.fractal", "subSettings/subInfluence", ""), + ("ec.noise.turbulent", "transform/scale", "percent"), + ("ec.noise.turbulent", "transform/scaleWidth", "percent"), + ("ec.noise.turbulent", "transform/scaleHeight", "percent"), + ("ec.noise.turbulent", "complexity", "percent"), + ("ec.noise.turbulent", "subSettings/subInfluence", ""), + ("ec.key.colorkey", "edgeFeather", ""), + ("ec.obsolete.lightning", "branchAngle", ""), + ("ec.key.luma", "edgeFeather", ""), + ("ec.blur.reduceflicker", "softness", ""), + ("ec.perspective.cameratracker", "trackPointSize", "percent"), + ("ec.perspective.cameratracker", "targetSize", "percent"), + ("ec.perspective.cccylinder", "lightHeight", "pixels"), + ("ec.sim.ccbubbles", "bubbleSize", "percent"), + ("ec.sim.ccdrizzle", "displacement", ""), + ("ec.sim.ccdrizzle", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "longevity", ""), + ("ec.sim.ccmrmercury", "blobInfluence", "percent"), + ("ec.sim.ccmrmercury", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "shading/metal", ""), + ("ec.sim.ccparticlesystems2", "birthRate", ""), + ("ec.sim.ccparticlesystems2", "longevity", ""), + ("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"), + ("ec.sim.ccrainfall", "windVariation", ""), + ("ec.sim.ccrainfall", "opacity", ""), + ("ec.sim.ccsnowfall", "sizeVariation", ""), + ("ec.sim.ccsnowfall", "speedVariation", ""), + ("ec.sim.ccsnowfall", "windVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""), + ("ec.sim.ccsnowfall", "opacity", ""), + ("ec.sim.ccstarburst", "speed", "percent"), + ("ec.sim.ccstarburst", "size", "pixels"), + ("ec.stylize.ccblockload", "completion", ""), + ("ec.stylize.ccburnfilm", "burn", ""), + ("ec.stylize.cchextile", "smearing", ""), + ("ec.stylize.cckaleida", "size", "percent"), + ("ec.stylize.ccvignette", "pinHighlights", ""), + ("ec.stylize.cartoon", "detailThreshold", ""), + ("ec.stylize.cartoon", "fill/shadingSmoothness", ""), + ("ec.stylize.cartoon", "edge/edgeSoftness", ""), + ("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""), + ("ec.stylize.glow", "colorLoops", "percent"), + ("ec.stylize.roughenedges", "stretch", "pixels"), + ("ec.time.pixelmotionblur", "vectorDetail", ""), + ("ec.transition.blockdissolve", "blockWidth", "percent"), + ("ec.transition.blockdissolve", "blockHeight", "percent"), + ("ec.transition.ccgridwipe", "border", "pixels"), + ("ec.transition.ccgridwipe", "tiles", "percent"), + ("ec.transition.cclinesweep", "completion", ""), + ("ec.transition.ccscalewipe", "stretch", "pixels"), + ("ec.transition.venetian", "width", "percent"), + ("ec.transition.venetian", "feather", ""), + ("ec.utility.cineon", "highlightRolloff", ""), + ("ec.utility.facemeasurements", "headOrientationX", "degrees"), + ("ec.utility.facemeasurements", "headOrientationY", "degrees"), + ("ec.utility.facemeasurements", "headOrientationZ", "degrees"), + ("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetX", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetY", "percent"), +]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7be59c277..db9e02cda 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,7 @@ pub use home::templates; pub use interchange::psd_import; pub use interchange::vector; pub use interchange::xml_project; +use interchange::{keyframe_text, keyframe_text_effects}; pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; pub use settings::autosave; pub use settings::config; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index df07c633b..9296e1322 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option { /// Recent Script Files), `history` (undo steps, newest /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and -/// `scriptPanels` (ScriptUI panels). +/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here, +/// and [`dynamic_nodes`] puts it in the folder's submenu). pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { use serde_json::json; let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec { @@ -167,6 +168,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option< } } +/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder +/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸ +/// Animate.jsx`), placed where the folder sorts among the other entries. +pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { + let (entries, empty) = dynamic(s, name, cx); + let scripts = matches!(name, "scripts" | "scriptPanels"); + let mut out: Vec = vec![]; + for mut e in entries { + let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else { + out.push(MenuNode::Item(e)); + continue; + }; + e.label = file; + let open = out.iter_mut().find_map(|n| match n { + MenuNode::Submenu { label, children } if *label == folder => Some(children), + _ => None, + }); + match open { + Some(children) => children.push(MenuNode::Item(e)), + None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }), + } + } + (out, empty) +} + /// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or /// workspace, like After Effects). pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String { diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 27e9a7dce..a83f494d2 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -208,6 +208,16 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 { let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 }; ((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64 } + // An 18-byte header and 24-bit BGR, 32-bit BGRA or 8-bit grey (Alpha) pixels, uncompressed. + OutputFormat::TargaSequence => { + use effectcraft_project::render_queue::Channels; + let bpp = match om.channels { + Channels::Rgb => 3, + Channels::Rgba => 4, + Channels::Alpha => 1, + }; + (18 + w as u64 * h as u64 * bpp) as f64 + } OutputFormat::WebM => mbps(8.0) * px / fps, OutputFormat::Gif => w as f64 * h as f64 * 0.5, OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps, diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 4c39cc89e..d6bb96831 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -116,6 +116,25 @@ fn persists_in_project_file() { assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty()); } +#[test] +fn targa_sequence_output_module() { + let (mut s, _) = rq_session(); + let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap(); + let om = &s.project.render_queue[0].output; + assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha")); + assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}"); + // Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without. + assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0); + s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap(); + assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0); + let f = s.execute("renderQueue.formats", json!({})).unwrap(); + let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned(); + assert_eq!( + tga, + Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true})) + ); +} + #[test] fn failure_toast_names_the_first_reason() { struct Failing; @@ -186,7 +205,7 @@ fn without_exporter() { s.execute("renderQueue.add", json!({})).unwrap(); assert!(!s.is_enabled("renderQueue.render")); let f = s.execute("renderQueue.formats", json!({})).unwrap(); - assert_eq!(f["formats"].as_array().unwrap().len(), 13); + assert_eq!(f["formats"].as_array().unwrap().len(), 14); assert_eq!(f["formats"][0]["available"], false); } diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index adbd3ab81..2d425a4e6 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -50,8 +50,20 @@ pub trait Services: Send + Sync { pub struct FsServices; impl Services for FsServices { + /// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading + /// those never ends (#2134). fn read_file(&self, path: &str) -> std::io::Result> { - std::fs::read(path) + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file"))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { config::atomic_write(std::path::Path::new(path), data) @@ -70,3 +82,21 @@ pub trait Importer: Send + Sync { /// media layer reads from memory (the browser) register them; file-based ones need nothing. fn add_frames(&self, _paths: &[String]) {} } + +#[cfg(test)] +mod tests { + use super::{FsServices, Services}; + + /// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error. + #[test] + fn fs_services_read_only_regular_files() { + let dir = std::env::temp_dir(); + assert!(FsServices.read_file(&dir.to_string_lossy()).is_err()); + #[cfg(unix)] + assert!(FsServices.read_file("/dev/zero").is_err()); + let f = dir.join(format!("ec-services-read-{}.txt", std::process::id())); + std::fs::write(&f, b"data").unwrap(); + assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/engine/src/settings/config.rs b/crates/engine/src/settings/config.rs index 00e7377b7..17d7c940a 100644 --- a/crates/engine/src/settings/config.rs +++ b/crates/engine/src/settings/config.rs @@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync { } Ok(text) } + /// Write entry `name`, replacing any earlier text. fn write(&self, name: &str, data: &str) -> std::io::Result<()>; + /// Remove entry `name`; a missing entry is no error. fn remove(&self, name: &str) -> std::io::Result<()>; /// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`), /// sorted. Stores that can't list return nothing. @@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync { let _ = dir; vec![] } + /// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`]. + fn list_dirs(&self, dir: &str) -> Vec { + let _ = dir; + vec![] + } /// The folder next to the configuration (default Auto-Save folder for untitled projects), /// in the namespace of [`ConfigStore::files`]; `None` when there is none. fn dir(&self) -> Option { @@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig { let Ok(m) = self.files.lock() else { return vec![] }; m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect() } + /// The first name part of the keys under `dir/`. + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{}/", dir.trim_end_matches('/')); + let Ok(m) = self.files.lock() else { return vec![] }; + let mut v: Vec = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect(); + v.sort(); + v.dedup(); + v + } } /// Files in one directory, written atomically. @@ -153,6 +169,13 @@ impl ConfigStore for DirConfig { v.sort(); v } + /// The folders in `dir`, following links like [`ConfigStore::list`] does for files. + fn list_dirs(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] }; + let mut v: Vec = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + } fn remove(&self, name: &str) -> std::io::Result<()> { match std::fs::remove_file(self.dir.join(name)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..aa683e2b8 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index ec5785f7e..857a3e90d 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -5,15 +5,15 @@ //! 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. //! -//! 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: +//! Levels by default ([`DEFAULT_FILTER`]): `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 +//! Frontends call [`install`] (or [`install_with`] for another default, as the CLI does with `warn`) +//! 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. @@ -131,11 +131,21 @@ fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: /// Install the logger (once; later calls and other installed loggers are left alone). pub fn install() { + install_with(DEFAULT_FILTER); +} + +/// The directive string: `RUST_LOG` when it is set and nonblank, otherwise `default`. +fn filter_spec<'a>(rust_log: Option<&'a str>, default: &'a str) -> &'a str { + rust_log.filter(|s| !s.trim().is_empty()).unwrap_or(default) +} + +/// [`install`] with `default` as the filter when `RUST_LOG` is unset or empty. +pub fn install_with(default: &str) { static LOGGER: Logger = Logger; if log::set_logger(&LOGGER).is_ok() { 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)) + let spec = std::env::var("RUST_LOG").ok(); + parse_filter(filter_spec(spec.as_deref(), default)) }); log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max)); } @@ -357,6 +367,21 @@ mod tests { assert_eq!(level_for(&f, "naga"), LevelFilter::Warn); } + /// The CLI's default (`warn`): warnings from every crate, no info chatter on stderr, + /// while a nonblank `RUST_LOG` overrides `default`. + #[test] + fn the_cli_filter_shows_only_warnings() { + assert_eq!(filter_spec(None, "warn"), "warn"); + assert_eq!(filter_spec(Some(" "), "warn"), "warn"); + assert_eq!(filter_spec(Some("info"), "warn"), "info"); + let f = parse_filter(filter_spec(None, "warn")); + assert_eq!(level_for(&f, "effectcraft_gpu::context"), LevelFilter::Warn); + assert_eq!(level_for(&f, "effectcraft_engine::render_queue"), LevelFilter::Warn); + assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn); + let over = parse_filter(filter_spec(Some("info"), "warn")); + assert_eq!(level_for(&over, "effectcraft_gpu::context"), LevelFilter::Info); + } + #[test] fn timestamps_are_utc_with_milliseconds() { assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 579d3d30d..25d05b903 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15,8 +15,10 @@ mod frame_export; mod fuzz_open; mod history; mod import_project; +mod instance3d; mod key_metadata_order; mod key_time; +mod keyframe_text; mod keylight; mod limits; mod lottie; diff --git a/crates/engine/src/tests/anim_presets.rs b/crates/engine/src/tests/anim_presets.rs index b9f529c45..4d8b79e3f 100644 --- a/crates/engine/src/tests/anim_presets.rs +++ b/crates/engine/src/tests/anim_presets.rs @@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() { #[test] fn preset_catalogue_is_unique_and_categorised() { let all = crate::animation_presets(); - let known = ["Transitions", "Backgrounds"]; + let known = ["Transitions", "Backgrounds", "Behaviors"]; let mut ids = std::collections::HashSet::new(); let mut names = std::collections::HashSet::new(); for (id, name, cat) in &all { @@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() { assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes"); } let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count(); - assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20); + assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20); } #[test] @@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() { assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0])); assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2); } + +/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform +/// property, and undo removes it again. +#[test] +fn every_behavior_preset_applies_and_undoes() { + let (mut s, solid) = session(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + let driven = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + ["transform/position", "transform/scale", "transform/rotation", "transform/opacity"] + .iter() + .filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression())) + .copied() + .collect::>() + }; + for p in &list { + let id = p["id"].as_str().unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies"); + assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects"); + let applied = driven(&s); + assert_eq!(applied.len(), 1, "{id}: exactly one property is driven"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(driven(&s).is_empty(), "{id}: undo removes the expression"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(driven(&s), applied, "{id}: redo restores the expression"); + s.execute("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index eacae55af..2e61f57a1 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); let layer = &comp.layers[0]; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; diff --git a/crates/engine/src/tests/disk_cache.rs b/crates/engine/src/tests/disk_cache.rs index 119699aa8..41497dd4b 100644 --- a/crates/engine/src/tests/disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() { assert_ne!(k3, k2); assert_ne!(set_proxy(&mut s, false), k3); } + +/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to +/// render again, #1083); an expression that reads markers makes them count. +#[test] +fn markers_keep_the_content_key_unless_an_expression_reads_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap(); + assert_eq!(key(&s), k0, "markers"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0); + s.execute("comp.addMarker", json!({"time": 1.0})).unwrap(); + assert_ne!(key(&s), k1, "a marker read by an expression"); +} + +/// A comp marker alone (no layer marker beside it) keeps the key too. +#[test] +fn a_lone_comp_marker_keeps_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0); +} + +/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so +/// changing it changes the key of the comp that nests it; a plain marker beside it does not. +#[test] +fn protected_markers_with_a_duration_change_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("layer.timeStretch", json!({"percent": 200})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0, "a plain marker"); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap(); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0, "a protected marker with a duration"); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap(); + assert_ne!(key(&s), k1, "its duration"); +} diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs index 1ea57f4cf..64baf14fe 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -772,6 +772,132 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a +/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value +/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the +/// old id applies to the new one. +#[test] +fn applying_an_old_preset_upgrades_its_effects_and_property_paths() { + let (mut s, a, b) = comp_with_two_solids(); + apply(&mut s, a, "Change to Color"); + let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone(); + old.children.retain(|c| c.match_id() != "toleranceGroup"); + old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into()); + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + let l = layer(&s, b); + let g = l.effects().unwrap().groups().next().unwrap(); + assert!(g.get("tolerance").is_none(), "the old Tolerance is still there"); + let tol = g.sub("toleranceGroup").expect("no Tolerance group"); + assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0)); + assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0)); + assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0)); + let path = "effects/ec.color.changetocolor/tolerance"; + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + assert_eq!(r["skipped"], json!([]), "{r}"); + let l = layer(&s, a); + let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone(); + assert_eq!(hue, KV::Scalar(30.0)); +} + +/// Save the selected effects and properties as a preset, then apply it to `target` at `at`. +fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value { + let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string(); + s.state.selected_props = selected; + s.execute("anim.savePreset", json!({"path": path})).unwrap(); + s.execute("time.set", json!({"time": at})).unwrap(); + let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]})); + let _ = std::fs::remove_file(&path); + r.unwrap() +} + +/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when +/// `n` is `None`. +fn key_times(s: &Session, lid: u64, n: Option, path: &str) -> Vec { + let l = layer(s, lid); + let group = match n { + Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(), + None => l.props.clone(), + }; + group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect() +} + +/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved +/// property's do, nested controls included, an effect without keys still applies, and the whole +/// apply is one undo step. +#[test] +fn preset_effect_keys_start_at_the_playhead() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let fractal = apply(&mut s, a, "Fractal"); + let slider = apply(&mut s, a, "Slider Control"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]); + animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]); + let before = layer(&s, b); + let undo = s.history.undo.len(); + let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5); + assert_eq!(r["applied"], 3, "{r}"); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]); + assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]); + assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, b), before); +} + +/// #1971: effects and properties saved together keep their relative timing: the earliest key of +/// either starts at the playhead. +#[test] +fn preset_effects_and_properties_share_one_anchor() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + for (time, value) in [(4.0, -12.0), (7.0, 12.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap(); + } + let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid; + save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0); + assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]); +} + +/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do. +#[test] +fn preset_effect_keys_follow_a_stretched_layer_like_properties() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + for (time, value) in [(5.0, -12.0), (6.0, 12.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap(); + } + let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid; + set_layer_clock(&mut s, b, 1.0, 200.0); + save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0); + let property = key_times(&s, b, None, "transform/rotation"); + assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead"); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them"); +} + +/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1) +/// has its effects' keys at the source layer's times; they also start at the playhead. +#[test] +fn old_preset_effect_keys_start_at_the_playhead() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + let old = json!({ + "effectcraftAnimationPreset": 1, + "name": "shake", + "properties": [], + "effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()], + }); + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap(); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]); +} + /// #2064: `prop.addKey` accepts the `effects/[#n]/` path that /// `essential.list` reports. #[test] diff --git a/crates/engine/src/tests/essential.rs b/crates/engine/src/tests/essential.rs index 191416b9d..db5bf0751 100644 --- a/crates/engine/src/tests/essential.rs +++ b/crates/engine/src/tests/essential.rs @@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() { assert!(center(&s, main)[0] > 0.99); } +/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested +/// layers past the precomp's frame instead of falling back to a flattened, clipped frame. +#[test] +fn collapsed_precomp_applies_property_overrides_without_clipping() { + struct AddY; + impl effectcraft_render::ExprHost for AddY { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + value: &effectcraft_keyframe::Value, + ) -> Result { + Ok(match value { + effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]), + _ => value.clone(), + }) + } + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + // The source property's expression still runs after the instance override. + s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap(); + s.expr = Some(std::sync::Arc::new(AddY)); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [other]})).unwrap(); + s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + // The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to + // outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above + // the instance's frame (150..449), where a flattened precomp would stop. The second maps to + // 470 and spans 420..519, past the bottom. + assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100)); + assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds"); + assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500)); + assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances"); +} + +/// #1747: two collapsed instances of the same comp keep their own source-affecting property +/// overrides in the layer cache (the nested layers' ids and static values are identical). +#[test] +fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let card = s.active_comp_id().unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap(); + let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let img = s.render(main, Tick::ZERO, RenderOpts::default()); + let left = img.get(60, 50); + let right = img.get(140, 50); + assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}"); + assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}"); +} + +/// #1747 with Media Replacement: the replacement footage and the position override both apply +/// while the precomp stays collapsed, so the box is blue and still draws above the frame. +#[test] +fn collapsed_precomp_applies_media_replacement_without_clipping() { + struct Colors; + impl crate::render::FootageSource for Colors { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c))) + } + } + let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() }; + let footage = |path: &str| effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 100, + height: 100, + has_video: true, + ..Default::default() + }; + let (red, blue) = s + .edit("test", None, |proj, _| { + let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png"))); + let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png"))); + Ok((red, blue)) + }) + .unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + let above = img.get(200, 100); + assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}"); + assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame"); +} + /// Responsive Design — Time: stretching a precomp keeps its protected regions at their /// original speed; the unprotected parts absorb the stretch. #[test] diff --git a/crates/engine/src/tests/essential_more.rs b/crates/engine/src/tests/essential_more.rs index a8d8dc808..ea4aa42e5 100644 --- a/crates/engine/src/tests/essential_more.rs +++ b/crates/engine/src/tests/essential_more.rs @@ -2,6 +2,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType}; +use effectcraft_time::Tick; use serde_json::json; use crate::{Event, Session}; @@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() { ); assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); } + +/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source +/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides` +/// does on the flattened path, instead of one entry replacing the other. +#[test] +fn collapsed_instance_merges_text_and_font_overrides() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let main = s.active_comp_id().unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + // Evaluate the nested text in the collapsed instance's context, as the renderer does. + let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default()); + let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO); + let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap(); + let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses"); + let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context"); + let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap(); + match nctx.value(src, src.props.prop("text/sourceText").unwrap()) { + KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } +} diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index a45c5d444..a8dc938fb 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() { assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); } + +#[test] +fn save_frame_as_png_preserves_alpha_channel() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap(); + let dir = out_dir("frame-png-alpha"); + let path = dir.join("alpha.png").to_string_lossy().to_string(); + + s.execute("comp.saveFrameAs", json!({"path": path})).unwrap(); + let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8(); + assert_eq!((img.width(), img.height()), (32, 32)); + let solid_px = img.get_pixel(8, 8); + assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}"); + assert_eq!(solid_px[0], 255); + let empty_px = img.get_pixel(24, 24); + assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}"); + + let flat_path = dir.join("flat.png").to_string_lossy().to_string(); + s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap(); + let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8(); + let flat_empty_px = flat_img.get_pixel(24, 24); + assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}"); +} + +#[test] +fn save_frame_as_queued_respects_frame_default_template() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + + // Default Still template is "PNG Sequence" (Channels::Rgb) + let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // Test undo and redo of queuing a frame + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + + // Explicit transparent: false forces Channels::Rgb + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // When Frame Default is set to an RGBA template, it is respected + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap(); + s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); + + // Passing transparent: false forces Channels::Rgb even with an RGBA template + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap(); + assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok()); +} diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs new file mode 100644 index 000000000..48f4b7730 --- /dev/null +++ b/crates/engine/src/tests/instance3d.rs @@ -0,0 +1,257 @@ +//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack +//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its +//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde). + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project}; +use effectcraft_render::{FootageSource, RenderOpts}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::{Importer, Session}; + +fn session() -> Session { + let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +fn cid(s: &Session) -> ItemId { + s.active_comp_id().unwrap() +} + +fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer { + s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] { + img.data[(y * img.width + x) as usize] +} + +fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) +} + +struct DiskModels; + +impl FootageSource for DiskModels { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + let bytes = std::fs::read(&f.path).ok()?; + effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new) + } +} + +struct ModelImporter; + +impl Importer for ModelImporter { + fn probe(&self, path: &str) -> Result { + effectcraft_model::format_of(path).ok_or("not a model")?; + Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() }) + } +} + +/// A red triangle in the Z = 0 plane (buffer embedded as a data URI). +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +fn triangle_model(s: &mut Session) -> u64 { + let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Triangle.gltf"); + std::fs::write(&path, TRIANGLE).unwrap(); + s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn instance3d_creates_a_2d_copy_of_the_model() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model renders through the comp's camera. + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180)); + assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4)); + // Create 3D Layer Instance. + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let comp = cid(&s); + let l = layer_in(&s, comp, solid); + assert_eq!(l.name, "Triangle Instance"); + assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack"); + // A 3D Layer Instance effect referencing the 3D layer. + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); + // The model is switched off, the copy selected. + assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does"); + assert_eq!(s.state.selected_layers, vec![LayerId(solid)]); + // The comp renders the same through the copy: a 2D copy of the 3D layer, transparent + // around it. + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); + assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4)); + // Undo removes the copy and switches the model back on; redo returns it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none()); + assert!(layer_in(&s, comp, model).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some()); + assert!(!layer_in(&s, comp, model).switches.video); + // Serde round-trips the layer parameter. + let j = serde_json::to_string(&*s.project).unwrap(); + let back: Project = serde_json::from_str(&j).unwrap(); + assert_eq!(back, *s.project); + // 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue + // recolours the triangle. + s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default()); + let p = px(&with_fx, 320, 180); + assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}"); +} + +#[test] +fn instance3d_also_takes_3d_primitives() { + let mut s = session(); + let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap(); + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let l = layer_in(&s, cid(&s), solid); + assert_eq!(l.name, "Cube Instance"); + assert!(!layer_in(&s, cid(&s), prim).switches.video); + let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the cube"); +} + +#[test] +fn instance3d_needs_a_3d_layer() { + let mut s = session(); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]})); + assert!(err.is_err()); + let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); + // A 3D-switched text layer is not a model layer either (it has 2D pixels of its own). + s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); +} + +#[test] +fn instance3d_creates_every_selected_copy_in_one_undo() { + let mut s = session(); + let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 2); + let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap()); + let comp = cid(&s); + assert_ne!(ia, ib); + assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own"); + // Both sources are off, both copies selected, above their sources in the stack. + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); + let mut sel = s.state.selected_layers.clone(); + let mut want = vec![LayerId(ia), LayerId(ib)]; + sel.sort_by_key(|l| l.0); + want.sort_by_key(|l| l.0); + assert_eq!(sel, want); + let c = s.active_comp().unwrap(); + let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap(); + assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources"); + // One undo removes both copies and switches both models back on; one redo returns them. + s.execute("edit.undo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance"); + assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance"); + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); +} + +#[test] +fn instance3d_in_a_mixed_selection_only_takes_the_models() { + let mut s = session(); + let model = triangle_model(&mut s); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 1, "only the model gets a copy: {r}"); + let copy = copies[0].as_u64().unwrap(); + let comp = cid(&s); + assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off"); + assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched"); + let skipped = r["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 1); + assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}"); + // The copy references the model, not the solid. + let l = layer_in(&s, comp, copy); + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); +} + +#[test] +fn instance3d_shows_nothing_outside_the_source_layer_range() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model's range ends at 1 s; the comp runs to 2 s. + let comp = cid(&s); + s.edit("trim", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let _copy = r["layers"][0].as_u64().unwrap(); + // Inside the model's range the copy shows it, transparent around it. + let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default()); + assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180)); + // After its out point the source has nothing to read: the copy is transparent. + let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default()); + assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180)); +} + +#[test] +fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() { + let mut s = session(); + let model = triangle_model(&mut s); + let comp = cid(&s); + // Solo the model, then create the instance: any_solo hides non-soloed layers and the + // source's video goes off, so the copy has to take the Solo over. + s.edit("solo", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true; + Ok(()) + }) + .unwrap(); + let before = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180)); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch"); + assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off"); + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180)); + assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); +} diff --git a/crates/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs new file mode 100644 index 000000000..30d3b436e --- /dev/null +++ b/crates/engine/src/tests/keyframe_text.rs @@ -0,0 +1,482 @@ +//! Copied keyframes as After Effects keyframe text for other applications (#1908). + +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::{KeyRef, Session}; + +/// A 1920×1080 23.976 fps comp with a 200×100 solid. +fn session() -> (Session, LayerId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + (s, LayerId(id)) +} + +fn frame(s: &Session, n: i64) -> Tick { + Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n) +} + +fn uid(s: &Session, lid: LayerId, path: &str) -> Uid { + s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid +} + +fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) { + let cid = s.active_comp_id().unwrap(); + f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap()); +} + +fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) { + s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap(); +} + +#[test] +fn copied_keys_are_after_effects_text_in_comp_frames() { + let (mut s, l) = session(); + // The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19. + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] { + let t = frame(&s, n); + add_key(&mut s, l, "transform/position", t, json!(v)); + } + let t = frame(&s, 5); + add_key(&mut s, l, "transform/rotation", t, json!(-45)); + let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation")); + // A subset: two of the three Position keys and the Rotation key. + s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!( + s.state.keyframe_text.as_deref(), + Some( + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Layer\n\ + Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\ + Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\ + \n\ + End of Keyframe Data\n" + ) + ); + // The internal clipboard still has the keys themselves, relative to the earliest. + let times: Vec> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect(); + assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]); +} + +#[test] +fn a_stretched_layer_writes_the_frames_its_keys_show_at() { + let (mut s, l) = session(); + edit_layer(&mut s, l, |l| l.stretch = 200.0); + let t = frame(&s, 6); + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0)); + add_key(&mut s, l, "transform/opacity", t, json!(100)); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}"); +} + +#[test] +fn copied_static_values_have_no_frame() { + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}"); + assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value"); +} + +#[test] +fn a_copied_transform_group_lists_what_the_layer_shows() { + let (mut s, l) = session(); + let t = frame(&s, 24); + add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100])); + add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100])); + let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); + assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}"); + // 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three + // values sit under one label, as After Effects writes them. + edit_layer(&mut s, l, |l| l.switches.three_d = true); + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}"); +} + +fn copy_transform(s: &mut Session, lid: LayerId) -> String { + let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +fn names(text: &str) -> Vec<&str> { + text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect() +} + +#[test] +fn properties_the_layer_does_not_use_are_left_out() { + // Separate Dimensions: the old Position no longer moves the layer. + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap(); + let text = copy_transform(&mut s, l); + assert!(!names(&text).contains(&"Position"), "{text}"); + // A one-node camera has no Point of Interest; a two-node one does. + let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, cam); + assert!(!names(&text).contains(&"Point of Interest"), "{text}"); + s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap(); + let text = copy_transform(&mut s, cam); + assert!(names(&text).contains(&"Point of Interest"), "{text}"); +} + +#[test] +fn separated_position_writes_x_y_and_z_position() { + let (mut s, l) = session(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100)); + add_key(&mut s, l, "transform/positionX", t, json!(321)); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap(); + let text = copy_transform(&mut s, l); + // After Effects labels their single column with no unit. + assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}"); + assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_transform(&mut s, l); + assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}"); +} + +#[test] +fn time_remap_writes_source_seconds_at_comp_frames() { + let (mut s, l) = session(); + let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap(); + let l = LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layers": [l.0]})).unwrap(); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + let t = frame(&s, 6); + add_key(&mut s, l, "timeRemap", t, json!(2.5)); + s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + // As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16. + assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}"); +} + +/// Selects the layer's properties at `paths` and copies them. +fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { + for (i, path) in paths.iter().enumerate() { + s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap(); + } + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +#[test] +fn effect_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown + // Menu Control: that block follows its other popups, written 1-based with no unit). + let (mut s, l) = session(); + for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let t = frame(&s, 12); + add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1])); + add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1])); + add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true)); + add_key(&mut s, l, "effects/#2/checkbox", t, json!(false)); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap(); + // After Effects writes a renamed effect under the effect's own name. + let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]); + assert!( + text.contains( + "Layer\n\ + Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\ + Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\ + Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\ + Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\ + Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n" + ), + "{text}" + ); +} + +#[test] +fn effect_properties_have_after_effects_indices() { + // After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9, + // Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a + // group takes an index where it opens and closes, and its properties are written without + // it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for + // its source choice. + let (mut s, l) = session(); + for fx in ["Fractal Noise", "Set Matte"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &[ + "effects/#1/transform/rotation", + "effects/#1/complexity", + "effects/#1/evolution", + "effects/#1/opacity", + "effects/#1/blendingMode", + "effects/#2/takeMatteFrom", + ], + ); + for header in [ + "Effects\tFractal Noise #1\tRotation #9\n", + "Effects\tFractal Noise #1\tComplexity #17\n", + "Effects\tFractal Noise #1\tEvolution #25\n", + "Effects\tFractal Noise #1\tOpacity #31\n", + "Effects\tFractal Noise #1\tBlending Mode #32\n", + "Effects\tSet Matte #2\tUse For Matte #3\n", + ] { + assert!(text.contains(header), "{header}in\n{text}"); + } +} + +#[test] +fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() { + // After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no + // unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color + // sliders start at #10 and are percent. + let (mut s, l) = session(); + for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"], + ); + for block in [ + "Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n", + "Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n", + "Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n", + "Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n", + "Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + +#[test] +fn mask_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not + // name, and Mask Path is left out. + let (mut s, l) = session(); + for _ in 0..2 { + s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + } + let t = frame(&s, 12); + for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] { + add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a); + add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b); + } + s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap(); + let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]); + assert!( + text.contains( + "Layer\n\ + Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\ + Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\ + Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\ + Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\ + \nEnd of Keyframe Data\n" + ), + "{text}" + ); +} + +#[test] +fn text_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same text properties and values. + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap(); + let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap(); + s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello")); + add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100)); + add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0])); + add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0])); + for (path, v) in [ + ("text/animators/#1/selectors/#1/end", json!(80)), + ("text/animators/#1/selectors/#1/offset", json!(-10)), + ("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])), + ("text/moreOptions/groupingAlignment", json!([10, -20])), + ] { + s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &[ + "text/sourceText", + "text/pathOptions/firstMargin", + "text/animators/#1/selectors/#1/start", + "text/animators/#1/selectors/#1/end", + "text/animators/#1/selectors/#1/offset", + "text/animators/#1/selectors/#1/advanced/amount", + "text/animators/#1/properties/position", + "text/animators/#1/properties/fillColor", + "text/animators/#2/properties/opacity", + "text/moreOptions/groupingAlignment", + ], + ); + for block in [ + "Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n", + "Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n", + ] { + assert!(text.contains(block), "{block}\nin\n{text}"); + } + assert!(!text.contains("Source Text"), "{text}"); +} + +#[test] +fn a_2d_layer_writes_the_z_it_shows() { + // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. + let (mut s, l) = session(); + let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + edit_layer(&mut s, src, |l| l.switches.three_d = true); + add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300])); + add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70])); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap(); + let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components(); + assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z"); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}"); + assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}"); +} + +#[test] +fn the_header_has_the_source_pixel_aspect() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap(); + let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, solid); + assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect. + let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, t); + assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A shape layer has no source of its own: the comp's size. + let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, shape); + assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}"); +} + +#[test] +fn keys_from_two_layers_have_no_text() { + let (mut s, a) = session(); + let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + for l in [a, b] { + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50)); + } + s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text"); + assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both"); + // A copied layer leaves no text behind. + s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }]; + s.execute("keys.copy", json!({})).unwrap(); + assert!(s.state.keyframe_text.is_some()); + s.state.selected_keys.clear(); + s.execute("edit.copy", json!({"layers": [a.0]})).unwrap(); + assert_eq!(s.state.keyframe_text, None); +} + +#[test] +fn shape_contents_are_written_as_after_effects_does() { + // Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and + // place in their group, and a Size has no unit (`X `). + let (mut s, _) = session(); + let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap()); + for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] { + let mut p = json!({"layer": sh.0, "kind": kind}); + if let Some(g) = group { + p["group"] = json!(g); + } + s.execute("layer.addShapeItem", p).unwrap(); + } + let text = copy_props( + &mut s, + sh, + &[ + "contents/#1/contents/#1/size", + "contents/#1/contents/#2/color", + "contents/#1/transform/position", + "contents/#2/points", + "contents/#3/transform/startOpacity", + ], + ); + for block in [ + "Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n", + "Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n", + "Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n", + "Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n", + "Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + +#[test] +fn camera_light_and_material_options_are_written_as_after_effects_does() { + // Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow + // Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0. + let (mut s, l) = session(); + let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]); + assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}"); + assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}"); + let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]); + assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}"); + assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]); + for block in [ + "Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n", + "Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n", + "Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index eb99d809f..93f81736f 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() { assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } +/// The layer's painted bounds mapped to comp space — what the viewer shows. +fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap(); + let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time()); + let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds"); + let m = ectx.layer_to_comp(layer).0; + let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + let pts: Vec<[f64; 2]> = corners + .iter() + .map(|p| { + let q = m.apply(effectcraft_geom::vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in &pts { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + [x0, y0, x1, y1] +} + +#[test] +fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { + let mut s = comp(); + let close = |x: f64, y: f64| (x - y).abs() < 1e-6; + // A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at + // the bounds' centre, so what the viewer shows fills the frame (#2198). + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, a); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}"); + + let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, t); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}"); + + // fitWidth spans the comp's width and centres vertically. + for lid in [a, t] { + s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap(); + let seen = seen_bounds(&s, lid); + assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}"); + assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}"); + } +} + +#[test] +fn fit_to_comp_refuses_a_scale_that_overflows() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap(); + let before = s.project.clone(); + assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused"); + assert_eq!(s.project, before, "nothing was written"); +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index f8d080bf9..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -14,7 +14,8 @@ use crate::Session; use crate::render::{FootageSource, RenderOpts}; /// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced: -/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a +/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from +/// the second frame), `matte_black.png` / `matte_white.png` (a /// semi-transparent green premultiplied against black / white, read as straight), `half.png` /// (white at 25 % alpha), `wide.png` (10×10 white). struct Synthetic; @@ -61,6 +62,11 @@ impl FootageSource for Synthetic { fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h); + // `flash.mov` is red for its first frame and blue from its second (0.1 s) on. + if f.path.ends_with("flash.mov") { + let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + return Some(Arc::new(Image::filled(w, h, c))); + } for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). @@ -182,6 +188,118 @@ fn comp_proxy_and_create_proxy() { assert!(blue(px(&s, outer, 0.0, o, 8, 8))); } +/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer). +fn nested(s: &mut Session) -> (ItemId, ItemId) { + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + (inner, outer) +} + +/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a +/// composition proxy draws, as they do for the same file used as footage. +#[test] +fn comp_proxy_draws_with_its_interpretation() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + // Invert Alpha: the 25 % proxy covers 75 %. + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((p[3] - 0.75).abs() < 0.01, "{p:?}"); + // Interpret As Linear Light: the proxy draws as the file does as footage with that setting. + let f = import(&mut s, "/m/matte_black.png"); + s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap(); + s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let r = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": f.0})).unwrap(); + let want = px(&s, r, 0.0, o, 1, 1); + assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}"); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}"); +} + +/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame +/// at twice the rate, as footage does. +#[test] +fn comp_proxy_separates_fields() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive"); + s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap(); + // At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated. + for y in 0..3 { + assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}"); + } +} + +/// Frame Blending on a precomp layer blends the frames of the composition's proxy. +#[test] +fn comp_proxy_frames_blend() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 1))); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + // Halfway between the red first frame and the blue second one. + let p = px(&s, outer, 0.05, o, 1, 1); + assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}"); + assert!(blue(px(&s, outer, 0.1, o, 1, 1))); +} + +#[test] +fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() { + struct Interpreted(std::sync::atomic::AtomicUsize); + impl FootageSource for Interpreted { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let pixel = match f.alpha { + AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5], + AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5], + AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0], + }; + Some(Arc::new(Image::filled(4, 4, pixel))) + } + } + for mode in ["frameMix", "pixelMotion"] { + let mut s = session(); + let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0))); + s.footage = footage.clone(); + let (inner, outer) = nested(&mut s); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": mode})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + let opts = RenderOpts::default(); + let first = px(&s, outer, 0.05, opts, 1, 1); + let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first); + assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls); + for params in [ + json!({"item": inner.0, "alpha": "ignore"}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}), + ] { + s.execute("file.interpretProxy", params.clone()).unwrap(); + let mut fresh = s.renderer(opts); + fresh.cache = None; + let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1); + let actual = px(&s, outer, 0.05, opts, 1, 1); + assert_eq!(actual, want, "{mode} after {params}"); + } + } +} + #[test] fn interpret_proxy_settings_apply_to_the_proxy() { let mut s = session(); @@ -198,6 +316,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() { assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation"); } +/// Agents and the control channel run commands through `execute_checked`, which refuses keys a +/// params doc does not list. The Interpret Footage commands list every key they read. +#[test] +fn interpret_commands_accept_their_keys_when_checked() { + let mut s = session(); + let mov = import(&mut s, "/m/lines.mov"); + let b = import(&mut s, "/m/red16.png"); + s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap(); + s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap(); + let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage; + assert!(px.preserve_rgb); + assert_eq!(px.start_timecode, Some(25)); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap(); + assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]); + // `item` names one footage item, as `items` names several. + s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap(); + s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap(); + s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap(); + let f = match &s.project.item(mov).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + assert!(f.invert_alpha); + assert_eq!(f.premul_color, [0.0, 1.0, 0.0]); + assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1); +} + /// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines /// interpolated; the dominant field comes first. #[test] diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d801a743a..cf2518447 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. +#[test] +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. +#[test] +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); + } +} + +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); + // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + let orientation = layer_in(&s, cid(&s), light); + let keys = &orientation.props.prop("transform/orientation").unwrap().keys; + let last = keys.iter().rev().nth(1).expect("the penultimate key"); + assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing"); +} + // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 423af1d2f..64ee30d5e 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec { img.data.iter().map(|p| p[3]).collect() } +/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins, +/// in the render and through Undo / Redo (#1646). +#[test] +fn roto_brush_strokes_correct_earlier_ones() { + let (mut s, clip, _) = painted(center, "disk-1646.mov"); + let c = center(0); + let spot = [c[0], c[1] + 25.0]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let paint = |s: &mut Session, kind: &str| { + s.execute_checked( + "roto.stroke", + json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}), + ) + .unwrap(); + }; + assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte"); + paint(&mut s, "bg"); + assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it"); + paint(&mut s, "fg"); + assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it"); + assert!(iou_at(&mut s, clip, 0, c) > 0.95); + assert!(s.undo()); + assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back"); + assert!(s.redo()); + assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again"); +} + #[test] fn roto_brush_segments_and_propagates() { let (mut s, clip, uid) = painted(center, "disk-a.mov"); diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index 3e81961ce..cf6968e3f 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() { let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); - // Scripts and agents naming one frame without `sequence` still get its run. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); - let (_, f) = footage(&s, &r["items"][0]); - assert_eq!(f.kind, FootageKind::Sequence); + // #2018: scripts and agents naming one frame without `sequence` get that still, as + // `ImportOptions.sequence` defaults to false in After Effects. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still)); } #[test] @@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() { } #[test] -fn replace_footage_and_reload_keep_sequences() { +fn replace_footage_keeps_a_still_a_still() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // Replacing with a numbered still does not silently turn the item into a sequence (#2073). s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); let (name, f) = footage(&s, &item); - assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // JSON null is unspecified, not "on": the still stays a still. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Alphabetical is a sequence modifier, not a request to make one. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Asking for the run still brings it. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); +} + +#[test] +fn replace_footage_keeps_a_one_frame_run_a_sequence() { + let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // A numbered file with no other frame in its run is still the replacement for a sequence. + s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1)); + // A single-file source keeps the sequence kind too. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1)); + // An explicit `sequence: false` still asks for one frame. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap(); + let (_, f) = footage(&s, &item); + assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0)); +} + +#[test] +fn replace_footage_and_reload_keep_sequences() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); // Reload finds a new frame and keeps the interpretation. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap(); std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..03003a77b 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] diff --git a/crates/engine/src/tests/shortcut_cmds.rs b/crates/engine/src/tests/shortcut_cmds.rs index dd306ef93..c11885db1 100644 --- a/crates/engine/src/tests/shortcut_cmds.rs +++ b/crates/engine/src/tests/shortcut_cmds.rs @@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() { assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err()); } +#[test] +fn trimming_out_to_the_playhead_keeps_its_frame_visible() { + let (mut s, ids) = session([(1.0, 9.0); 3]); + let frame = 1.0 / 30.0; + // Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last + // visible frame — it must not be cut away at the playhead (as in After Effects). + s.execute("time.set", json!({"time": 4.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0])); + // Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap(); + assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1])); + // Trimmed before the In point, the Out point keeps one frame of layer. + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2])); + // One undo step back to the original span. + assert!(s.undo()); + assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9); +} + #[test] fn masks_are_stepped_through_and_wrap() { let (mut s, ids) = session([(0.0, 10.0); 3]); diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 84321ee7e..9735f2c08 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() { }; let radius = |v: [f64; 2]| v[0].hypot(v[1]); - // A 120 x 80 rectangle is its four corners, joined by straight edges. + // A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right + // corner clockwise as After Effects draws it. let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]})); - assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]); + assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]); assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}"); // Undo brings the parametric shape back. s.execute("edit.undo", json!({})).unwrap(); @@ -583,3 +584,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0; assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } + +/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a +/// shared merge key; one undo must revert the whole drag, not the last frame. +#[test] +fn free_transform_drag_undoes_in_one_step() { + let mut s = comp(); + let (l, m) = masked(&mut s); + s.state.selected_vertices.clear(); + let before = mask_path(&s, l, m); + for k in [90, 90, 90, 90] { + s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap(); + } + let after = mask_path(&s, l, m); + assert_ne!(after, before); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag"); +} diff --git a/crates/engine/src/tests/tail.rs b/crates/engine/src/tests/tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests/tail.rs +++ b/crates/engine/src/tests/tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// 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() { diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 17d0345d8..7a04ea155 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() { assert!(s.state.text_edit.is_none()); } +/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends +/// (#2226), as in After Effects — typing must not keep editing an unselected layer. +#[test] +fn duplicate_during_a_text_edit_ends_the_edit() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap(); + assert_ne!(copy, t, "the duplicate is created"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected"); + assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)"); + assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text"); + assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it"); +} + #[test] fn caret_style_applies_to_the_next_typed_text() { let (mut s, t) = session("ab"); @@ -260,25 +274,25 @@ fn clipboard_and_paste_formatting() { } #[test] -fn type_tool_layers_point_box_and_empty_cleanup() { +fn type_tool_layers_point_box_and_empty_layer_is_kept() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); - // Leaving an untouched Type-tool layer deletes it. + // Leaving an untouched Type-tool layer keeps it, as After Effects does. s.execute("text.endEdit", json!({})).unwrap(); - assert!(s.active_comp().unwrap().layers.is_empty()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); s.execute("text.insert", json!({"text": "Boxed"})).unwrap(); let d = doc(&s, b); assert_eq!(d.box_size, Some([300.0, 120.0])); assert_eq!(d.box_pos, [-150.0, -60.0]); - let l = s.active_comp().unwrap().layers[0].clone(); + let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone(); assert_eq!(l.name, "Boxed", "named after its text"); let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included"); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() { let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap(); assert_eq!(r["families"][0]["family"], "Noto Serif"); // The bundled face is Regular; the OS can also provide Bold, Italic, or other faces. - // Compare against the independent directory inventory rather than assuming an empty OS. - let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]); - expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone())); + // Model `add_source()` on the independent directory inventory: a system face whose family + // and primary style duplicate an earlier face is skipped, with its named instances. + let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]); + let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]); + for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) { + if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) { + expected_styles.insert(face.style.to_ascii_lowercase()); + expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase())); + } + } let styles: Vec = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect(); - assert_eq!(styles.iter().cloned().collect::>(), expected_styles); + // `families()` keeps the first spelling of a style that differs only in case. + assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::>(), expected_styles); assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates"); assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser"))); // The style menus offer a family's own styles. @@ -1202,7 +1224,7 @@ fn deselect_all_ends_the_open_edit() { } #[test] -fn deselect_all_discards_an_empty_type_tool_layer() { +fn deselect_all_keeps_an_empty_type_tool_layer() { let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!( @@ -1214,7 +1236,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects"); } diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 33dac7795..c912e0d87 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() { assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); } +/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index, +/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The +/// effect keeps its name; other properties, dimensions, insertion into an expression and match +/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the +/// references read the right values.) +#[test] +fn pick_whip_by_index_selects_effect_properties_by_their_index() { + let (mut s, l) = setup(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Color Control", "Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + // Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree. + let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone(); + let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1; + let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1; + let whip = |s: &mut Session, path: &str, target: Value, extra: Value| { + let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("prop.pickWhip", p).unwrap() + }; + let other = "thisComp.layer(\"Other\")"; + let undo = s.history.undo.len(); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})), + json!(format!("{other}.effect(\"Color Control\")(1)[0]")), + "a colour into one value: its first" + ); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})"); + assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested)); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})), + json!(format!("{other}.effect(\"Point Control\")(1)[1]")) + ); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})), + json!(format!("1 + {nested}")), + "inserted at the cursor" + ); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})), + json!(format!("{other}.transform.opacity")), + "only an effect's properties go by index" + ); + // Match names (Compact English off): the effect by its match name, its properties by index. + s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap(); + let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})); + assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick"); +} + /// #407: Reset without a value gives a property its own default (a Transform property's, an /// effect parameter's) besides removing its keyframes and expression. #[test] diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 5ed0848b9..1ff12237e 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() { fn masks_of_every_shape_tool_kind() { let mut s = comp(); let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); - for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] { + for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] { let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap(); let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); assert!(path.closed, "{kind}"); diff --git a/crates/engine/tests/it/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs index f9dd9ae3f..61440e273 100644 --- a/crates/engine/tests/it/parenting_3d_position.rs +++ b/crates/engine/tests/it/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 2ef6f5ad8..f2c185262 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-export" -description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF" +description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF" version.workspace = true edition.workspace = true license.workspace = true @@ -22,7 +22,7 @@ effectcraft-opusenc = { workspace = true } effectcraft-vp9enc = { workspace = true } effectcraft-hevcenc = { workspace = true } effectcraft-av1enc = { workspace = true } -image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] } +image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] } gif = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index e48bc4bb1..619d0a9d9 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -7,7 +7,7 @@ use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio}; @@ -19,6 +19,17 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError { ExportError::Io(e.to_string()) } +/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video +/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use +/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to +/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the +/// colours (pure red came back as [232,0,1]). +pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) { + if let Some(v) = entry.video.as_mut() { + v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); + } +} + pub(crate) struct Packet { pub(crate) data: Vec, pub(crate) key: bool, @@ -110,7 +121,9 @@ impl VideoEncoder for H264 { let (sps, pps) = self.enc.sps_pps(); filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); - SampleEntry::avc(cfg, self.w as u16, self.h as u16) + let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16); + tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v)); diff --git a/crates/export/src/hevc_av1.rs b/crates/export/src/hevc_av1.rs index f42ccda24..c363dff35 100644 --- a/crates/export/src/hevc_av1.rs +++ b/crates/export/src/hevc_av1.rs @@ -92,7 +92,9 @@ impl Hevc { impl VideoEncoder for Hevc { fn sample_entry(&self) -> SampleEntry { let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default(); - SampleEntry::hevc(cfg, self.w as u16, self.h as u16) + let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16); + crate::encode::tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let (w, h) = (self.w as usize, self.h as usize); @@ -148,7 +150,9 @@ impl Av1 { impl VideoEncoder for Av1 { fn sample_entry(&self) -> SampleEntry { let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default(); - SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16) + let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16); + crate::encode::tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let (w, h) = (self.w as usize, self.h as usize); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 7ea28b571..1b69a87bd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -7,6 +7,7 @@ //! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — | //! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — | +//! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — | //! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — | //! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | //! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) | @@ -335,19 +336,22 @@ fn write_still(cx: &Cx, file: &mut W, img: &I .write_image(&rgb, w, h, ExtendedColorType::Rgb8) .map_err(|e| ExportError::Encode(e.to_string())) } - OutputFormat::PngSequence | OutputFormat::TiffSequence => { + OutputFormat::PngSequence | OutputFormat::TiffSequence | OutputFormat::TargaSequence => { let px = cx.pixels(img, comp, channels, w, h); let (data, ct) = match channels { Channels::Rgba => (px, ExtendedColorType::Rgba8), Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8), Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), }; - if fmt == OutputFormat::PngSequence { - image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) - } else { + match fmt { + OutputFormat::PngSequence => image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct), + // Uncompressed (the encoder's run-length compression is off), top-left origin, the + // alpha bits in the image descriptor: 8 for RGB + Alpha, 0 otherwise. + OutputFormat::TargaSequence => image::codecs::tga::TgaEncoder::new(file).disable_rle().write_image(&data, w, h, ct), // (the TIFF encoder needs Seek) - image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + _ => image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct), } + .map_err(|e| ExportError::Encode(e.to_string())) } f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))), } @@ -372,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_ let end = (i + batch).min(st.total); let frames: Vec> = job .frames(comp, (i..end).collect(), |_, img| { - let mut px = job.pixels(&img, comp, job.output.channels, w, h); + let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false); gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) }) .await; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index ae52d3478..c639ebad1 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -4,10 +4,8 @@ use std::sync::Mutex; -use effectcraft_project::render_queue::{ - AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, -}; -use effectcraft_project::{Comp, Project}; +use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path}; +use effectcraft_project::{BitDepth, Comp, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -39,6 +37,57 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders +/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working +/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI +/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames. +pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT"; + +/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or +/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a +/// number or too large. +pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { + let v = var?; + if v.trim().is_empty() { + return None; + } + match v.trim().parse::() { + Ok(n) if n >= 1 => Some(n), + _ => { + log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped"); + None + } + } +} + +/// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for +/// the process's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Inflight { + pub gpu: Option, + pub cpu: Option, +} + +impl Inflight { + /// Frames rendered at once: the smaller cap while the GPU renders, and only the CPU cap once + /// it has stopped for good (the CPU fallback renders in parallel, #1511). `None`: uncapped. + pub fn renders(&self, gpu_live: bool) -> Option { + let gpu = self.gpu.filter(|_| gpu_live); + let n = match (gpu, self.cpu) { + (Some(g), Some(c)) => Some(g.min(c)), + (g, c) => g.or(c), + }; + n.map(|n| n.max(1)) + } + + /// Frames per window, rendered (`renders` at a time) and then encoded in parallel: with a CPU + /// cap at most that many, so finished images held before encoding count against it too; + /// with a GPU cap alone, one per thread (the images are in RAM, not VRAM). + pub fn window(&self, renders: usize) -> usize { + self.cpu.map_or(renders.max(rayon::current_num_threads()), |c| c.max(1)) + } +} + /// `render` then `encode` over the frames `ks`, results in `ks` order. /// /// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as @@ -55,38 +104,41 @@ pub(crate) fn map_frames( render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { - map_frames_while(ks, cap, || true, render, encode) + map_frames_while(ks, Inflight { gpu: cap, cpu: None }, || true, render, encode) } -/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped -/// for good, so every frame falls back to the CPU), the remaining frames render in parallel -/// like the uncapped CPU path instead of one chunk of `cap` at a time. +/// [`map_frames`] with the caps of `caps`, the GPU cap holding only while `gpu_live()`: once it +/// turns false (the GPU stopped for good, so every frame falls back to the CPU), the remaining +/// frames render under the CPU cap alone, in parallel when there is none, instead of one chunk +/// of the GPU cap at a time. A CPU cap also bounds each window ([`Inflight::window`]). pub(crate) fn map_frames_while( ks: Vec, - cap: Option, - capped: impl Fn() -> bool, + caps: Inflight, + gpu_live: impl Fn() -> bool, render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { use rayon::prelude::*; - let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else { + if caps.renders(gpu_live()).is_none() { return ks.into_par_iter().map(|k| encode(k, render(k))).collect(); - }; - let window = n.max(rayon::current_num_threads()); + } let mut out = Vec::with_capacity(ks.len()); - for (w, win) in ks.chunks(window).enumerate() { - if !capped() { - out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k)))); + let mut rest = &ks[..]; + while !rest.is_empty() { + let Some(n) = caps.renders(gpu_live()) else { + out.par_extend(rest.par_iter().map(|&k| encode(k, render(k)))); break; - } + }; + let (win, tail) = rest.split_at(caps.window(n).min(rest.len())); let mut imgs = Vec::with_capacity(win.len()); let mut i = 0; while i < win.len() { - let end = if capped() { (i + n).min(win.len()) } else { win.len() }; + let end = caps.renders(gpu_live()).map_or(win.len(), |n| (i + n).min(win.len())); imgs.par_extend(win[i..end].par_iter().map(|&k| render(k))); i = end; } out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img))); + rest = tail; } out } @@ -166,6 +218,9 @@ impl<'a> Cx<'a> { pub fn new(job: &'a Job<'a>) -> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); + // Render Settings ▸ Color Depth overrides the project's depth for the whole render (every + // layer and effect quantises at it), not just the finished frame (#2022). + project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); let cache = LayerCache::default(); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. @@ -186,7 +241,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -226,10 +281,10 @@ impl<'a> Cx<'a> { } return out; } - // The cap is for the GPU's memory: once it has stopped for good, frames render on the - // CPU in parallel. + // The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the + // CPU, in parallel or under the CPU cap (#1615), which holds throughout. let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) + map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f) } /// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the @@ -286,6 +341,11 @@ impl<'a> Cx<'a> { (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) } + /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). + fn inflight(&self) -> Inflight { + Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + } + /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. pub fn frame(&self, comp: &Comp, i: u64) -> Image { effectcraft_render::passes::block_on(self.frame_async(comp, i)) @@ -301,14 +361,6 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } } }; - let mut img = img; - // Color Depth: renders run at the project's depth; another depth quantises the frame. - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current - && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels() - { - img.quantize(levels); - } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -325,12 +377,14 @@ impl<'a> Cx<'a> { return None; } let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None }; - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() { + // Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the + // accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't + // band (#2022). The project's depth already includes Render Settings ▸ Color Depth. + if self.project.settings.bit_depth != BitDepth::Bpc8 { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None; @@ -363,7 +417,7 @@ impl<'a> Cx<'a> { let gpu_live = || self.job.accel.is_none_or(|a| a.available()); map_frames_while( ks, - self.gpu_cap(), + self.inflight(), gpu_live, |k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)), |k, r| match r { @@ -376,7 +430,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { @@ -394,12 +448,24 @@ impl<'a> Cx<'a> { /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte. + /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After + /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients + /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to + /// dither, so its output stays byte for byte what it was. pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let dither = self.project.settings.bit_depth != BitDepth::Bpc8; + self.pixels_with(img, comp, channels, w, h, dither) + } + + /// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise). + pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec { let px = match channels { + Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background), Channels::Rgb => img.to_rgba8_over(comp.background), - Channels::Rgba => match self.job.output.alpha_mode { - AlphaMode::Straight => img.to_rgba8(), - AlphaMode::Premultiplied => premultiplied8(img), + Channels::Rgba => match (self.job.output.alpha_mode, dither) { + (AlphaMode::Straight, true) => img.to_rgba8_dithered(), + (AlphaMode::Straight, false) => img.to_rgba8(), + (AlphaMode::Premultiplied, _) => premultiplied8(img, dither), }, Channels::Alpha => img .data @@ -560,21 +626,42 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image { out } -/// Premultiplied 8-bit RGBA (colour matted with black). -fn premultiplied8(img: &Image) -> Vec { - img.data - .iter() - .flat_map(|p| { - let a = p[3].clamp(0.0, 1.0); - [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8]) - }) - .collect() +/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`. +fn premultiplied8(img: &Image, dither: bool) -> Vec { + let w = img.width.max(1) as usize; + let mut out = Vec::with_capacity(img.data.len() * 4); + for (i, p) in img.data.iter().enumerate() { + let a = p[3].clamp(0.0, 1.0); + let a8 = (a * 255.0 + 0.5) as u8; + let (x, y) = ((i % w) as u32, (i / w) as u32); + for c in 0..3 { + let v = p[c].clamp(0.0, a); + // Dither may round up past the rounded alpha; premultiplied colour never exceeds it. + out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 }); + } + out.push(a8); + } + out } #[cfg(test)] mod tests { use super::*; use rayon::prelude::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + /// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or + /// below the written alpha at every pixel. + #[test] + fn dithered_premultiplied_colour_never_exceeds_alpha() { + let v = 100.25 / 255.0; + let img = Image::filled(64, 64, [v, v, v, v]); + let px = premultiplied8(&img, true); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha"); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100)); + let plain = premultiplied8(&img, false); + assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100])); + } /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { @@ -607,7 +694,7 @@ mod tests { let out = pool.install(|| { map_frames_while( (0..24).collect(), - Some(1), + Inflight { gpu: Some(1), cpu: None }, || live.load(Ordering::SeqCst), |k| { if k == 1 { @@ -767,6 +854,103 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn cpu_inflight_is_off_unless_a_positive_number() { + assert_eq!(cpu_inflight(None), None); + assert_eq!(cpu_inflight(Some("2")), Some(2)); + assert_eq!(cpu_inflight(Some(" 4 ")), Some(4)); + for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] { + assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn cpu_cap_holds_and_combines_with_the_gpu_cap() { + // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. + let caps = |gpu, cpu| Inflight { gpu, cpu }; + for live in [true, false] { + assert_eq!(caps(None, None).renders(live), None); + assert_eq!(caps(None, Some(2)).renders(live), Some(2)); + } + assert_eq!(caps(Some(1), None).renders(true), Some(1)); + assert_eq!(caps(Some(1), None).renders(false), None); + assert_eq!(caps(Some(3), Some(2)).renders(true), Some(2)); + assert_eq!(caps(Some(1), Some(4)).renders(true), Some(1)); + // Review of #1615: once the GPU stops, the CPU cap applies, not the GPU's 1. + assert_eq!(caps(Some(1), Some(4)).renders(false), Some(4)); + // A CPU cap of 2 on 8 threads renders at most 2 frames at once. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| map_frames_while((0..16).collect(), caps(None, Some(2)), || true, |k| track(&now, &max, k), |_, k| k)); + assert_eq!(out, (0..16).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "{max:?} frames in flight"); + } + + /// Counts `k` in flight in `now`, records the most in `max`, and holds it for a moment. + fn track(now: &AtomicUsize, max: &AtomicUsize, k: u64) -> u64 { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + k + } + + #[test] + fn a_cpu_cap_bounds_the_images_waiting_to_encode() { + // Review of #1615: a window of one image per thread was rendered before any encoded, so + // a cap of 2 on 8 threads still held 8 finished images. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + for gpu in [None, Some(1)] { + let (held, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..20).collect(), + Inflight { gpu, cpu: Some(2) }, + || true, + |k| { + let c = held.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + k + }, + |k, img| { + held.fetch_sub(1, Ordering::SeqCst); + assert_eq!(k, img); + k + }, + ) + }); + assert_eq!(out, (0..20).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "gpu {gpu:?}: {max:?} images held"); + } + } + + #[test] + fn the_cpu_cap_takes_over_once_the_gpu_stops() { + // Review of #1615: with both caps, frames after the GPU stopped rendered one at a time. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..40).collect(), + Inflight { gpu: Some(1), cpu: Some(4) }, + || live.load(Ordering::SeqCst), + |k| { + if k == 1 { + live.store(false, Ordering::SeqCst); + } + if k < 4 { + return k; + } + track(&now, &max, k) + }, + |_, k| k, + ) + }); + assert_eq!(out, (0..40).collect::>()); + let max = max.load(Ordering::SeqCst); + assert!((2..=4).contains(&max), "{max} frames in flight after the GPU stopped"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs new file mode 100644 index 000000000..3f99f1eb6 --- /dev/null +++ b/crates/export/tests/it/banding.rs @@ -0,0 +1,156 @@ +//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the +//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the +//! render itself. + +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::*; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 1024; +const H: u32 = 16; +/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px, +/// so plain rounding makes bands about 40 px wide. +const LO: f64 = 0.20; +const HI: f64 = 0.30; +/// Width of the column blocks whose mean is compared with the ideal ramp. +const WINDOW: u32 = 8; + +/// A grey horizontal ramp over a comp of the given project depth. +fn ramp_project(depth: BitDepth) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + comp.background = [0.0, 0.0, 0.0]; + let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect"); + let mut next = p.next_id; + let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + for (k, v) in [ + ("start", Value::Vec2([0.0, 0.0])), + ("end", Value::Vec2([W as f64, 0.0])), + ("startColor", Value::Color([LO, LO, LO, 1.0])), + ("endColor", Value::Color([HI, HI, HI, 1.0])), + ] { + group.get_mut(k).expect("ramp parameter").value = v; + } + layer.props.sub_mut("effects").expect("effects group").children.push(group.into()); + comp.layers.push(layer); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of +/// every row. +fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec> { + let files: Arc)>>> = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); + let settings = + RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() }; + let om = OutputModule::for_format(OutputFormat::PngSequence); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: "/banding/f_[#####].png", + sink: Some(&*sink), + options: JobOptions::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); + let files = files.lock().unwrap(); + let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG"); + let img = image::load_from_memory(png).expect("png").to_rgba8(); + (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect() +} + +/// The ideal ramp at column `x`, in 8-bit codes. +fn ideal(x: f64) -> f64 { + 255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64) +} + +/// Longest run of equal neighbours in the row: a band's width. +fn longest_run(row: &[u8]) -> usize { + let (mut best, mut run) = (1, 1); + for w in row.windows(2) { + run = if w[0] == w[1] { run + 1 } else { 1 }; + best = best.max(run); + } + best +} + +/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a +/// flat step, hundreds when the steps are dithered. +fn transitions(rows: &[Vec]) -> usize { + rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum() +} + +/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows +/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step +/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05). +fn worst_window_error(rows: &[Vec]) -> f64 { + (0..W / WINDOW) + .map(|i| { + let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize); + let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum(); + let mean = sum / (rows.len() * (x1 - x0)) as f64; + (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs() + }) + .fold(0.0, f64::max) +} + +#[test] +fn a_16_bpc_gradient_export_is_dithered() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + let rows = export_rows(&p, cid, ColorDepth::Current); + let (steps, err) = (transitions(&rows), worst_window_error(&rows)); + eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off + // by up to ~0.4 codes. + assert!(steps > 2000, "the steps survive: {steps} transitions"); + assert!(err < 0.25, "the local mean is off by {err} codes"); + // Dither never moves a value a whole code from the ideal ramp. + for row in &rows { + for (x, &v) in row.iter().enumerate() { + let want = ideal(x as f64); + assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}"); + } + } +} + +#[test] +fn the_export_is_the_same_every_time() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current)); +} + +#[test] +fn the_render_color_depth_reaches_the_render() { + // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither. + let (p, cid) = ramp_project(BitDepth::Bpc8); + let banded = export_rows(&p, cid, ColorDepth::Current); + assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export"); + assert!(transitions(&banded) < 16 * 30); + // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision + // until the final conversion, which dithers. + let deep = export_rows(&p, cid, ColorDepth::Bpc16); + let (steps, err) = (transitions(&deep), worst_window_error(&deep)); + eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}"); + // And 8 bpc on a 16 bpc project renders at 8 bits. + let (p16, cid16) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded); +} diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 59e670779..0f99a7b54 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_color::Label; -use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_raster::Image; @@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { assert!(a == c); } +/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes +/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022). +#[test] +fn renders_above_8_bpc_skip_the_planes() { + let d = out_dir("h264-gpu-yuv-16bpc"); + let (p, cid) = project(); + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() }; + let yuv = Fake::new(true, false, usize::MAX); + let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4")); + let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); + assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc"); + assert!(a == c); +} + /// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never /// through the blocking per-frame path the planes use. #[test] diff --git a/crates/export/tests/it/hevc_av1_roundtrip.rs b/crates/export/tests/it/hevc_av1_roundtrip.rs index 93a82719a..4022da1ca 100644 --- a/crates/export/tests/it/hevc_av1_roundtrip.rs +++ b/crates/export/tests/it/hevc_av1_roundtrip.rs @@ -123,7 +123,15 @@ fn ffprobe_video(path: &Path) -> Option { .output() .expect("ffprobe"); assert!(out.status.success() && out.stderr.is_empty(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); - Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // An ffprobe whose build lacks this codec's decoder (distro "free" builds) only parses the + // container: pix_fmt and the counted frames come back unknown/N/A, describing the build + // rather than our output. Skip the oracle, as when ffprobe is absent. + if s.contains("|unknown|") || s.ends_with("|N/A") { + eprintln!("ffprobe cannot decode this codec here: skipping the oracle ({s})"); + return None; + } + Some(s) } /// Decode every frame with ffmpeg (no errors allowed) and check frames 0, 4, 5 and the last. @@ -140,7 +148,14 @@ fn ffmpeg_check(path: &Path, decoder: Option<&str>, tol: f32) { cmd.args(["-c:v", d]); } let out = cmd.arg("-i").arg(path).args(["-pix_fmt", "rgba"]).arg(dir.join("f%03d.png")).output().expect("ffmpeg"); - assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + // Same as ffprobe: an ffmpeg without this codec's decoder cannot judge our output, so the + // oracle skips instead of failing (the in-repo decode above already proved the frames). + if !out.status.success() && err.contains("no decoder found") { + eprintln!("ffmpeg cannot decode this codec here: skipping the oracle decode ({err})"); + return; + } + assert!(out.status.success() && err.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), err); for k in [0, 4, 5, FRAMES - 1] { let img = image::open(dir.join(format!("f{:03}.png", k + 1))).expect("ffmpeg frame").to_rgba8(); check_frame(&Image::from_rgba8(img.width(), img.height(), img.as_raw()), k, tol); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 9b37d1809..88398a0e8 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod audio_sample_positions; +mod banding; mod h264_gpu_yuv; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] @@ -12,5 +13,6 @@ mod native_audio_sample_positions; mod opus_roundtrip; mod render_settings; mod roundtrip; +mod targa_sequence; mod uncompressed_mov; mod vp9_roundtrip; diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 5f1f8eb6b..1f48efd1e 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -94,7 +94,7 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4"); + assert_eq!((data.len(), fnv1a(&data)), (104_180, 8_728_430_532_616_484_528), "H.264 MP4"); } #[test] diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index 0fc747018..477191f64 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -23,6 +23,24 @@ const H: u32 = 48; const BG: [f32; 3] = [0.1, 0.1, 0.4]; const FRAMES: u64 = 12; +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free" +/// builds often omit `libx264` but ship an alternative (e.g. libopenh264), so pick the first +/// present by name rather than hard-coding one that may be absent. The oracle only needs *an* +/// H.264 encoder; the encoder identity is irrelevant to the assertion. +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + fn out_dir(name: &str) -> PathBuf { let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); let _ = std::fs::remove_dir_all(&d); @@ -260,6 +278,8 @@ fn image_sequences_roundtrip() { (OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb), (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb), (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba), + (OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgb), + (OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgba), ] { let d = out_dir(&format!("seq-{ext}-{ch:?}")); let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) }; @@ -489,14 +509,18 @@ fn ffmpeg_audio(src: &Path) -> Vec { fn movies_carry_resampled_footage_audio() { let d = out_dir("audio-44k"); let clip = d.join("clip.mp4"); + let Some(enc) = h264_encoder() else { + eprintln!("ffmpeg has no H.264 encoder: skipping"); + return; + }; let made = Command::new("ffmpeg") .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=30000/1001:duration=3", "-f", "lavfi", "-i"]) - .args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) .arg(&clip) .status() .is_ok_and(|s| s.success()); if !made { - eprintln!("ffmpeg not found: skipping"); + eprintln!("ffmpeg could not make the H.264 fixture: skipping"); return; } let fps = FrameRate::new(30000, 1001); diff --git a/crates/export/tests/it/targa_sequence.rs b/crates/export/tests/it/targa_sequence.rs new file mode 100644 index 000000000..b496163d2 --- /dev/null +++ b/crates/export/tests/it/targa_sequence.rs @@ -0,0 +1,218 @@ +//! Targa sequence output: every file is an uncompressed TGA (Truevision TGA 2.0 specification: +//! an 18-byte header, BGR(A) pixels, top-left origin, the alpha bits in the image descriptor) +//! holding exactly the pixels the lossless PNG sequence of the same job writes, and EffectCraft's +//! own Targa import (#745) reads the frames back as the same picture. +//! +//! The comp is 7×4 @ 10 fps, 3 frames: frame 0 is the background alone, frames 1 and 2 add a +//! 2×2 solid in the top two rows (so a flipped picture shows), on a half pixel horizontally, so +//! its left and right columns carry partial alpha. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{AlphaMode, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path}; +use effectcraft_export::{Job, export}; +use effectcraft_keyframe::Value; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 7; +const H: u32 = 4; +const FRAMES: u64 = 3; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +fn project() -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0)); + comp.background = [0.1, 0.3, 0.6]; + let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 2, height: 2, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (2, 2), None); + l.props.prop_mut("transform/position").expect("position").value = Value::Vec3([3.5, 1.0, 0.0]); + l.in_point = comp.frame_rate.tick_of(1); + comp.layers.push(l); + let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Render the comp through `om` to `dir/f_[#####].`; the first frame's path. +fn render(p: &Project, cid: ItemId, om: &OutputModule, dir: &Path) -> String { + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let path = dir.join(format!("f_[#####].{}", om.format.extension())).to_string_lossy().to_string(); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &s, + output: om, + path: &path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!((r.frames, r.width, r.height), (FRAMES, W, H)); + r.path +} + +/// The numbered files of a render of `ext`: frames 0..FRAMES and nothing after. +fn frame_paths(dir: &Path, ext: &str) -> Vec { + let template = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string(); + assert!(!Path::new(&sequence_path(&template, FRAMES as i64)).exists(), "no frame after the last"); + (0..FRAMES as i64).map(|k| sequence_path(&template, k)).collect() +} + +/// A Targa file's header fields and its pixels as top-down RGBA bytes. +struct Tga { + image_type: u8, + width: u16, + height: u16, + depth: u8, + descriptor: u8, + rgba: Vec, +} + +fn read_tga(path: &str) -> Tga { + let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}")); + let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]); + assert_eq!((b[0], b[1]), (0, 0), "{path}: no image ID, no colour map"); + assert_eq!((u16_at(8), u16_at(10)), (0, 0), "{path}: X / Y origin"); + let (width, height, depth, descriptor) = (u16_at(12), u16_at(14), b[16], b[17]); + let bpp = usize::from(depth / 8); + // Uncompressed, without an image ID, colour map or 2.0 footer: header and pixels only. + assert_eq!(b.len(), 18 + usize::from(width) * usize::from(height) * bpp, "{path}: file size"); + let rgba = b[18..] + .chunks(bpp) + .flat_map(|p| match p { + [b, g, r, a] => [*r, *g, *b, *a], + [b, g, r] => [*r, *g, *b, 255], + [v] => [*v, *v, *v, 255], + _ => panic!("{path}: {depth}-bit pixels"), + }) + .collect(); + Tga { image_type: b[2], width, height, depth, descriptor, rgba } +} + +fn png_rgba(path: &str) -> Vec { + image::open(path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8().into_raw() +} + +#[test] +fn targa_sequence_writes_the_png_sequence_pixels() { + let (p, cid) = project(); + for (channels, alpha_mode, image_type, depth, descriptor) in [ + (Channels::Rgb, AlphaMode::Straight, 2, 24, 0x20), + (Channels::Rgba, AlphaMode::Straight, 2, 32, 0x28), + (Channels::Rgba, AlphaMode::Premultiplied, 2, 32, 0x28), + (Channels::Alpha, AlphaMode::Straight, 3, 8, 0x20), + ] { + let name = format!("tga-{channels:?}-{alpha_mode:?}"); + let tga_dir = out_dir(&name); + let png_dir = out_dir(&format!("{name}-png")); + let om = OutputModule { channels, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) }; + let first = render(&p, cid, &om, &tga_dir); + assert!(first.ends_with("f_00000.tga"), "{first}"); + render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, ..om.clone() }, &png_dir); + let mut frames = Vec::new(); + for (tga, png) in frame_paths(&tga_dir, "tga").iter().zip(frame_paths(&png_dir, "png")) { + let t = read_tga(tga); + assert_eq!((t.image_type, t.width, t.height, t.depth, t.descriptor), (image_type, W as u16, H as u16, depth, descriptor), "{tga}"); + assert_eq!(t.rgba, png_rgba(&png), "{tga} matches {png}"); + frames.push(t.rgba); + } + // Frame 0 is background only; frames 1 and 2 hold the solid, so the numbering follows + // the comp's frames. + let at = |f: &[u8], x: u32, y: u32| -> [u8; 4] { + let i = ((y * W + x) * 4) as usize; + [f[i], f[i + 1], f[i + 2], f[i + 3]] + }; + assert_ne!(at(&frames[0], 3, 1), at(&frames[1], 3, 1), "{name}"); + assert_eq!(frames[1], frames[2], "{name}"); + // Top-down rows: the solid is in the top half, the bottom rows are still background. + assert_eq!(at(&frames[1], 3, 3), at(&frames[0], 3, 3), "{name}: bottom row"); + match channels { + Channels::Rgb => { + assert_eq!(at(&frames[0], 0, 0)[3], 255); + assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid"); + } + Channels::Rgba => { + assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 0], "{name}: transparent background"); + assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid"); + let edge = at(&frames[1], 2, 1); + assert!(edge[3] > 0 && edge[3] < 255, "{name}: partial alpha at the solid's edge {edge:?}"); + if alpha_mode == AlphaMode::Straight { + // Unmatted from the renderer's premultiplied pixel: the solid's colour, within rounding. + assert!(edge[..3].iter().zip([230u8, 51, 26]).all(|(a, b)| a.abs_diff(b) <= 1), "{name}: straight colour {edge:?}"); + } else { + assert!(edge[0] < 230 && edge[0] <= edge[3], "{name}: premultiplied colour {edge:?}"); + } + } + Channels::Alpha => { + assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 255], "{name}: no matte yet"); + assert_eq!(at(&frames[1], 3, 1), [255, 255, 255, 255], "{name}: the solid's matte"); + } + } + } +} + +/// Numbered stills read back as an image sequence through the media layer (Targa: #745). +fn import_sequence(paths: &[String]) -> (Footage, MediaPool) { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let first = effectcraft_media::probe(&paths[0]).expect("probe our output"); + assert_eq!((first.kind, first.width, first.height), (FootageKind::Still, W, H)); + for p in paths { + let bytes: Arc<[u8]> = std::fs::read(p).expect("read").into(); + pool.add_bytes(p, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.to_vec(), ..first }; + footage.sync_sequence_duration(); + (footage, pool) +} + +#[test] +fn targa_sequence_round_trips_through_targa_import() { + let (p, cid) = project(); + for alpha_mode in [AlphaMode::Straight, AlphaMode::Premultiplied] { + let name = format!("tga-import-{alpha_mode:?}"); + let tga_dir = out_dir(&name); + let png_dir = out_dir(&format!("{name}-png")); + let om = OutputModule { channels: Channels::Rgba, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) }; + render(&p, cid, &om, &tga_dir); + // The reference: the same job as a straight-alpha PNG sequence (PNG's alpha is labelled). + render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, alpha_mode: AlphaMode::Straight, ..om.clone() }, &png_dir); + let (mut tga, tga_pool) = import_sequence(&frame_paths(&tga_dir, "tga")); + let (png, png_pool) = import_sequence(&frame_paths(&png_dir, "png")); + assert_eq!(tga.codec, "TGA"); + assert_eq!(tga.sequence_frames(), FRAMES as i64); + // Targa doesn't label its alpha: import guesses straight, and Interpret Footage sets + // premultiplied for a premultiplied render. + assert_eq!(tga.alpha, effectcraft_project::AlphaMode::Straight); + if alpha_mode == AlphaMode::Premultiplied { + tga.alpha = effectcraft_project::AlphaMode::Premultiplied; + } + // Premultiplying to 8 bits rounds partially transparent colour. + let tol = if alpha_mode == AlphaMode::Straight { 0.0 } else { 1.0 / 255.0 }; + for k in 0..FRAMES as i64 { + let a = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(k)).expect("decode Targa"); + let b = png_pool.frame_at(&png, png.frame_rate.tick_of(k)).expect("decode PNG"); + assert_eq!((a.width, a.height), (W, H)); + for (x, y) in a.data.iter().zip(&b.data) { + assert!(x.iter().zip(y).all(|(x, y)| (x - y).abs() <= tol), "{name} frame {k}: {x:?} != {y:?}"); + } + } + let opaque = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(1)).expect("decode"); + assert_eq!(opaque.data[opaque.idx(0, 0)][3], 0.0, "{name}: the background stays transparent"); + assert_eq!(opaque.data[opaque.idx(3, 1)][3], 1.0, "{name}: the solid is opaque"); + } +} diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index a25fbc4f0..fa2993f97 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -10,7 +10,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; -use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_render::EvalCtx; use effectcraft_time::Tick; /// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). @@ -299,7 +299,17 @@ impl<'a> Resolver<'a> { } fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; - Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage }) + let same_comp = c == self.ctx.comp_id.0; + Some(EvalCtx { + project: self.ctx.project, + comp_id: ItemId(c), + comp, + time: Tick::from_seconds_f64(t), + expr: self.ctx.expr, + footage: self.ctx.footage, + property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, + media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { let layer = self.layer(c, l)?; @@ -372,7 +382,7 @@ impl<'a> Resolver<'a> { Req::LayerInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let (mut w, mut h) = source_size(project, l); + let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l); if w == 0 || h == 0 { (w, h) = (c.width, c.height); } @@ -467,7 +477,7 @@ impl<'a> Resolver<'a> { } } _ => { - let (w, h) = source_size(project, l); + let (w, h) = ctx.source_size(l); [0.0, 0.0, w as f64, h as f64] } }; @@ -501,7 +511,12 @@ impl<'a> Resolver<'a> { Req::SourceInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let it = project.item(l.source.item()?)?; + let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?; + let item = match &l.source { + LayerSource::Footage { item } => ctx.media_item(l, *item), + _ => l.source.item()?, + }; + let it = project.item(item)?; let (kind, aspect, frame, duration) = match &it.kind { ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..9e78d250f 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -17,7 +17,8 @@ //! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, //! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the //! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ -//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`), +//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have. //! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read @@ -162,7 +163,16 @@ fn evaluate_out( 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 ctx = EvalCtx { + project: ctx.project, + comp_id: ctx.comp_id, + comp: ctx.comp, + time: ctx.time, + expr: Some(host), + footage: ctx.footage, + property_overrides: ctx.property_overrides, + media_overrides: ctx.media_overrides, + }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 6611d36aa..8fe812590 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -207,6 +207,60 @@ function ease() { return __interp(__ease, arguments); } function easeIn() { return __interp(__easeIn, arguments); } function easeOut() { return __interp(__easeOut, arguments); } +// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1 +// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection +// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four +// finite numbers is linear, never an error. +function __cubicBezier(curve) { + var x1, y1, x2, y2; + if (Array.isArray(curve) && curve.length === 4) { + x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; + } + if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || + !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return __lin; + } + x1 = Math.max(0, Math.min(1, x1)); + x2 = Math.max(0, Math.min(1, x2)); + function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } + function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } + function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } + return function (x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var u = x; + for (var i = 0; i < 8; i++) { + var f = bx(u) - x; + var d = dbx(u); + if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); + var n = u - f / d; + if (!(n >= 0 && n <= 1)) break; + u = n; + } + var lo = 0, hi = 1; + for (var j = 0; j < 60; j++) { + var mid = (lo + hi) / 2; + if (bx(mid) < x) lo = mid; else hi = mid; + } + return by((lo + hi) / 2); + }; +} + +// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is +// the sixth / fourth argument. Read it by position, not from the end: when it is missing or +// unusable, the interpolation falls back to linear without consuming the last value. +function bezier() { + var a = arguments, n = a.length; + if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]); + if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]); + if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]); + return __interp(__lin, [a[0], a[1], a[2]]); +} + // --------------------------------------------------------------------------------------------- // Random numbers and noise. @@ -670,14 +724,17 @@ var __groupHandler = { }, }; -function __mkGroup(c, l, info) { - var f = function (k) { return f.__get(k); }; - delete f.name; - delete f.length; - Object.setPrototypeOf(f, __GroupProto); - f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; - return new Proxy(f, __groupHandler); -} +// `Proxy` is private to the object model (see "Stack safety" at the end). +var __mkGroup = (function (P) { + return function (c, l, info) { + var f = function (k) { return f.__get(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, __GroupProto); + f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; + return new P(f, __groupHandler); + }; +})(Proxy); // Properties ------------------------------------------------------------------------------------ @@ -852,9 +909,11 @@ var __propHandler = { }, }; -function __mkProp(c, l, path) { - return new Proxy(new Prop(c, l, path), __propHandler); -} +var __mkProp = (function (P) { + return function (c, l, path) { + return new P(new Prop(c, l, path), __propHandler); + }; +})(Proxy); // Text styles (the Source Text style API) ------------------------------------------------------ // A TextStyle is the source document (as JSON from the host), the character index it reads its @@ -1069,3 +1128,101 @@ function velocityAtTime(t) { return thisProperty.velocityAtTime(t); } function speedAtTime(t) { return thisProperty.speedAtTime(t); } function key(n) { return thisProperty.key(n); } function nearestKey(t) { return thisProperty.nearestKey(t); } + +// --------------------------------------------------------------------------------------------- +// Stack safety (#2124, #2125). A stack overflow aborts the whole app, so nothing a script does +// may nest native code without bound. The expression text is checked for nesting before it is +// compiled; source handed to `eval` and the function constructors at run time is checked by +// `__nest`. The natives that walk nested values recursively (`join`, which `toString` and string +// conversion use, `toLocaleString`, `JSON.stringify`, `flat`) call `__stack` once per level, +// which throws once the run has used its share of the stack. `Proxy` (chains of proxies nest +// without any script frame) and `Reflect` are not available to expressions. +(function () { + var nest = __nest, stack = __stack, apply = Reflect.apply, construct = Reflect.construct; + var define = Object.defineProperty, describe = Object.getOwnPropertyDescriptor, proto = Object.getPrototypeOf; + // Replace `obj[name]` keeping its attributes. + function replace(obj, name, f) { + var d = describe(obj, name); + define(obj, name, { value: f, writable: d.writable, enumerable: d.enumerable, configurable: true }); + } + function guard(obj, name) { + var f = obj[name]; + replace(obj, name, function () { stack(); return apply(f, this, arguments); }); + } + + // Indirect on purpose: a wrapper can't make a direct eval, so `eval` sees globals and `var`s + // but not `let`/`const` locals of the expression. Keeping direct eval would mean leaving it + // unchecked; expressions practically never need their locals inside eval. + var evalJs = eval; + eval = function (x) { + if (typeof x === 'string') { + stack(); + nest(x); + } + return evalJs(x); + }; + // `Function` and the generator / async variants (reachable as `(function* () {}).constructor`). + function dynamic(F) { + var w = function () { + var args = []; + for (var i = 0; i < arguments.length; i++) args.push(String(arguments[i])); + stack(); + nest(args.slice(0, -1).join(',')); + nest(args.length ? args[args.length - 1] : ''); + return construct(F, args); + }; + define(w, 'name', { value: F.name, configurable: true }); + w.prototype = F.prototype; + replace(F.prototype, 'constructor', w); + return w; + } + Function = dynamic(Function); + dynamic(proto(function* () {}).constructor); + dynamic(proto(async function () {}).constructor); + dynamic(proto(async function* () {}).constructor); + + guard(Array.prototype, 'join'); + guard(Array.prototype, 'toLocaleString'); + var flat = Array.prototype.flat; + replace(Array.prototype, 'flat', function (depth) { + stack(); + var d = depth === undefined ? 1 : Number(depth); + return apply(flat, this, [d > 64 ? 64 : d]); + }); + + // A replacer function sees every value before it is serialized: the per-level check. An array + // replacer (a list of keys) becomes one that copies each object's listed keys, in list order. + // Each source object gets one copy per call, so a cycle in the source is a cycle of copies and + // the native check reports it (TypeError); an object shared without a cycle is still fine. + var stringify = JSON.stringify, MapJs = Map; + replace(JSON, 'stringify', function (value, replacer, space) { + var fn = typeof replacer === 'function' ? replacer : null; + var keys = null, copies = new MapJs(); + if (Array.isArray(replacer)) { + keys = []; + for (var i = 0; i < replacer.length; i++) { + var k = replacer[i]; + if (typeof k === 'number' || k instanceof Number || k instanceof String) k = String(k); + if (typeof k === 'string' && keys.indexOf(k) < 0) keys.push(k); + } + } + return apply(stringify, JSON, [value, function (key, v) { + stack(); + if (fn !== null) v = apply(fn, this, [key, v]); + if (keys !== null && v !== null && typeof v === 'object' && !Array.isArray(v) && + !(v instanceof Number || v instanceof String || v instanceof Boolean)) { + var o = copies.get(v); + if (o === undefined) { + o = {}; + for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + copies.set(v, o); + } + v = o; + } + return v; + }, space]); + }); + + delete globalThis.Proxy; + delete globalThis.Reflect; +})(); diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 35c373bb4..34b52439b 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -805,6 +805,12 @@ pub fn check_nesting(src: &str) -> Result<(), String> { Ok(()) } +/// An error when plain JavaScript `src` (not rewritten: what `eval` and `Function` get at run +/// time) nests deeper than [`MAX_NESTING`] levels. +pub fn check_plain_nesting(src: &str) -> Result<(), String> { + if too_nested(&tokenize(src).0) { Err(too_deep()) } else { Ok(()) } +} + fn too_deep() -> String { format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)") } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 53c2960ba..29e5e4eda 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -51,6 +51,8 @@ thread_local! { pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; /// Script runs since the outermost evaluation on this thread began (nested ones included). pub static RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Stack address where the current script run began (0: none; see [`native_stack`]). + static STACK_BASE: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[cfg(not(target_arch = "wasm32"))] @@ -232,17 +234,54 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + +/// `__stack()`: throw once the current run has used [`NATIVE_STACK`] (values nested so deeply +/// would overflow the stack, which aborts the app). +fn native_stack(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + let base = STACK_BASE.with(|b| b.get()); + if base != 0 && base.abs_diff(stack_here()) > NATIVE_STACK { + return Err(JsNativeError::range().with_message("value is nested too deeply").into()); + } + Ok(JsValue::undefined()) +} + +/// `__nest(src)`: throw when source handed to `eval` / `Function` at run time nests deeper than +/// the parser's stack allows (the expression text itself is checked before it is compiled). +fn native_nesting(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let src = string(args, 0, ctx)?; + crate::rewrite::check_plain_nesting(&src).map_err(|e| JsNativeError::range().with_message(e.trim_start_matches("Error: ").to_string()))?; + Ok(JsValue::undefined()) +} + impl Runtime { fn new() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ + (js_string!("__stack"), 0, native_stack), (js_string!("__h"), 6, native_host), (js_string!("__wig"), 8, native_wiggle), (js_string!("__noise"), 3, native_noise), (js_string!("__rnd"), 6, native_random), + (js_string!("__nest"), 1, native_nesting), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; @@ -361,6 +400,9 @@ pub fn out_of_time() -> bool { } } +/// Levels of nested arrays a script result keeps. +const MAX_DETACH: usize = 4; + /// Copy a JS value out of the heap. fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { if v.is_undefined() { @@ -380,7 +422,7 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { } let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX); if let Some(o) = v.as_object() { - if o.is_array() && depth < 4 { + if o.is_array() && depth < MAX_DETACH { let arr = JsArray::from_object(o).map_err(err)?; let n = arr.length(ctx).map_err(err)?.min(1 << 16); let mut out = Vec::with_capacity(n as usize); @@ -408,6 +450,11 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { return Ok(Out::Path { points, ins, outs, closed }); } } + // Below the levels detached above, an object's text could be its whole nested content + // (`String([[[…]]])`), converted by natives that recurse once per level. + if depth >= MAX_DETACH && v.is_object() { + return Ok(Out::Object("[object]".into())); + } Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default())) } @@ -484,6 +531,8 @@ fn position_line(msg: &str) -> Option { /// `__begin` with memoized answers, so nothing carries over. A script that really overflows the /// stack fails again on the fresh context and reports the error. pub fn run(text: &str, b: &Begin) -> Run { + // Runs don't nest (other expressions are evaluated between runs), so one base suffices. + STACK_BASE.with(|s| s.set(stack_here())); RUNTIME.with(|r| { let mut r = r.borrow_mut(); for fresh in [false, true] { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..55720a327 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1,5 +1,6 @@ use std::time::Instant; +mod deep_nesting; mod fuzz; use effectcraft_color::Label; @@ -313,8 +314,78 @@ fn interpolation_with_arrays() { assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_curves() { + let f = fx(); + // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. + assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); + // Symmetric ease-in-out: slower at both ends, faster in the middle. + let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); + // Control points on the diagonal are linear. + assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); + // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. + assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); + assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); + assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); +} +#[test] +fn cubic_bezier_short_form_arrays_and_fallbacks() { + let f = fx(); + // Short form: no tMin / tMax. + assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); + assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); + // y outside 0..1 overshoots, as CSS allows. + let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); + assert!(over > 100.0, "overshoot: {over}"); + // A curve that is not four finite numbers falls back to linear instead of throwing. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // A missing or unusable curve must not consume the last value: read the curve by position. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); + let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); + // x control points outside 0..1 are clamped, as CSS requires. + let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); + assert!(v.is_finite(), "clamped curve: {v}"); +} + +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} + +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); @@ -907,6 +978,49 @@ fn source_rect_at_time() { assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } +#[test] +fn source_dimensions_follow_media_replacement() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = |p: &mut Project, name: &str, width, height| { + p.add_item( + name, + Label::SeaFoam, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: name.into(), + kind: effectcraft_project::FootageKind::Still, + width, + height, + has_video: true, + ..Default::default() + }), + ) + }; + let original = footage(&mut p, "original.png", 100, 80); + let replacement = footage(&mut p, "replacement.png", 30, 40); + let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let media = effectcraft_render::MediaOverride::new(lid, replacement); + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[media], + }; + assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); +} + // ---- colour ----------------------------------------------------------------------------------- #[test] @@ -1114,7 +1228,16 @@ fn text_expression_selector_values() { let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); - let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs new file mode 100644 index 000000000..ae78c5b20 --- /dev/null +++ b/crates/expr/src/tests/deep_nesting.rs @@ -0,0 +1,130 @@ +//! Deep nesting the expression text check can't see (#2124, #2125): source handed to `eval` / +//! `Function` at run time, and deeply nested values that boa's natives (`join`, `JSON.stringify`, +//! proxies…) walk recursively. Each used to overflow the stack, which aborts the whole app +//! (`catch_unwind` can't catch it); now each is an expression error. + +use super::*; + +/// The render threads' stack size (rayon's default): what the app gets. +const RENDER_STACK: usize = 2 << 20; + +/// Evaluate `text` on the title's source text (any result converts) on a render-sized stack. +fn eval_small_stack(text: &str) -> Result { + eval_on_stack(text, RENDER_STACK) +} + +/// Evaluate `text` on the title's source text on a thread with `size` bytes of stack. +fn eval_on_stack(text: &str, size: usize) -> Result { + let text = text.to_string(); + std::thread::Builder::new() + .stack_size(size) + .spawn(move || { + let f = fx(); + f.ev(f.title, "text/sourceText", &text, 0.0) + }) + .unwrap() + .join() + .unwrap() +} + +fn text_of(r: Result) -> String { + match r { + Ok(Value::Text(d)) => d.text, + other => panic!("not text: {other:?}"), + } +} + +/// A 100 000-deep array: `var a = []; … a = [a]` in a loop (under the loop limit). +const DEEP_ARRAY: &str = "var a = []; for (var i = 0; i < 100000; i++) a = [a];"; + +#[test] +fn eval_and_function_constructors_check_nesting() { + let deep = r#""(".repeat(1000) + "1" + ")".repeat(1000)"#; + for call in [ + format!("eval({deep})"), + format!("Function({deep})()"), + format!("new Function('a', {deep})(1)"), + format!("Function('a = ' + {deep}, 'return a')()"), + "Function.apply(null, '('.repeat(1000).split('').concat(['1']))".to_string(), + format!("(function () {{}}).constructor({deep})()"), + format!("Object.getPrototypeOf(function* () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function* () {{}}).constructor({deep})"), + ] { + let e = eval_small_stack(&call).expect_err(&call); + assert!(e.contains("nested too deeply"), "{call}: {e}"); + } +} + +#[test] +fn eval_and_function_still_work() { + let f = fx(); + assert_eq!(f.num("eval('1 + 2')", 0.0), 3.0); + assert_eq!(f.num("eval('[1, [2, (3)]]')[1][1]", 0.0), 3.0); + assert_eq!(f.num("new Function('a', 'b', 'return a * b')(3, 4)", 0.0), 12.0); + assert_eq!(f.num("Function('return 5')()", 0.0), 5.0); + assert_eq!(f.num("(function () {}).constructor('return 6')()", 0.0), 6.0); + assert_eq!(f.num("(function () {}) instanceof Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("(function () {}).constructor === Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("Object.getPrototypeOf(function* () { yield 7; }).constructor('yield 7')().next().value", 0.0), 7.0); +} + +#[test] +fn deep_array_result_is_an_error_or_text_not_a_crash() { + // The result itself: detaching it used to `toString` it past depth 4. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} a")); + // Explicit conversions inside the script. + for tail in ["a.join()", "a.toString()", "String(a)", "a + ''", "`${a}`", "a.toLocaleString()", "JSON.stringify(a)", "JSON.stringify({ a: a })"] { + let text = format!("{DEEP_ARRAY} {tail}"); + let e = eval_small_stack(&text).expect_err(&text); + assert!(e.contains("nested too deeply"), "{text}: {e}"); + } + // A deep array as a text style value goes through JSON.stringify in the prelude. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} text.sourceText.style.setFontSize(a)")); +} + +#[test] +fn deep_proxy_chain_is_an_error_not_a_crash() { + let text = "var p = {}; for (var i = 0; i < 100000; i++) p = new Proxy(p, {}); p.x"; + let e = eval_small_stack(text).expect_err(text); + assert!(e.contains("Proxy") && e.contains("not defined"), "{text}: {e}"); + // Neither is available to expressions; the object model's own proxies still work. + let f = fx(); + assert_eq!(f.text("typeof Proxy + ' ' + typeof Reflect", 0.0), "undefined undefined"); + assert_eq!(f.num("thisComp.layer('A').effect('Slider Control')('Slider')", 0.0), 42.0); +} + +#[test] +fn nested_values_still_convert() { + let f = fx(); + assert_eq!(f.text("[[1, 2], [3, [4, 5]]].join(';')", 0.0), "1,2;3,4,5"); + assert_eq!(f.text("String([[1, 2], 3])", 0.0), "1,2,3"); + assert_eq!(f.text("JSON.stringify({ a: [1, { b: [2] }] })", 0.0), r#"{"a":[1,{"b":[2]}]}"#); + assert_eq!(f.text("JSON.stringify({ a: 1, b: 2 }, ['b'])", 0.0), r#"{"b":2}"#); + assert_eq!(f.text("JSON.stringify([1, [2]], null, 1)", 0.0), "[\n 1,\n [\n 2\n ]\n]"); + assert_eq!(f.text("JSON.stringify({ a: 1 }, function (k, v) { return k === 'a' ? v + 1 : v; })", 0.0), r#"{"a":2}"#); + // Nested deeper than the detached levels: still converts to its text. + assert_eq!(text_of(eval_small_stack("[[[[[[1, 2]]]]]]")), ""); + assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1"); + assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0); +} + +/// The main thread runs commands and syntax checks: 1 MiB on Windows. Half of it, for the +/// callers above the evaluation, must still turn the nested-value triggers into errors. +#[test] +fn nested_values_are_an_error_on_half_the_main_thread() { + for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] { + let text = format!("{DEEP_ARRAY} {tail}"); + let e = eval_on_stack(&text, 512 << 10).expect_err(&text); + assert!(e.contains("nested too deeply"), "{tail}: {e}"); + } +} + +#[test] +fn stringify_with_a_key_list_reports_cycles_and_allows_shared_objects() { + let f = fx(); + let cyclic = f.text("var o = {a: 1}; o.self = o; try { JSON.stringify(o, ['a', 'self']) } catch (e) { e.name }", 0.0); + assert_eq!(cyclic, "TypeError"); + let shared = f.text("var x = {a: 1, b: 2}; JSON.stringify({p: x, q: x}, ['p', 'q', 'a'])", 0.0); + assert_eq!(shared, r#"{"p":{"a":1},"q":{"a":1}}"#); +} diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index 1bfaf8c16..37b28c415 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[ "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)", + "bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", + "bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])", "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)", diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index d86784f1e..32121ffcb 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -261,6 +261,9 @@ impl Mat3 { return Mat3([[x1 - x0, x3 - x0, x0], [y1 - y0, y3 - y0, y0], [0.0, 0.0, 1.0]]); } let den = dx1 * dy2 - dx2 * dy1; + if den == 0.0 || !den.is_finite() { + return Mat3::IDENTITY; + } let g = (sx * dy2 - dx2 * sy) / den; let h = (dx1 * sy - sx * dy1) / den; Mat3([[x1 - x0 + g * x1, x3 - x0 + h * x3, x0], [y1 - y0 + g * y1, y3 - y0 + h * y3, y0], [g, h, 1.0]]) @@ -449,6 +452,19 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 { mod tests { use super::*; + /// A quad with three collinear corners has no projective map: identity, not NaN. + #[test] + fn square_to_quad_degenerate_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 5.0), vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(20.0, 0.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite()), "non-finite: {m:?}"); + } + + #[test] + fn square_to_quad_regular_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(8.0, 6.0), vec2(0.0, 5.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite())); + } + fn close(a: Vec2, b: Vec2) -> bool { (a - b).length() < 1e-9 } diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index 39dc0bdea..a3f05bf12 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index bcd929304..44e777b5a 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -74,6 +74,8 @@ struct Lease { pool: Weak>, /// The [`Enc`] that took it (see [`Pool::owned`]). owner: u64, + /// The pool's [`Pool::generation`] when it was taken. + generation: u64, } impl std::fmt::Debug for Lease { @@ -87,7 +89,7 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release_owned(self.texture.clone(), self.owner); + p.release_owned(self.texture.clone(), self.owner, self.generation); } } } @@ -118,6 +120,8 @@ pub(crate) struct Pool { /// working texture of another `Enc` before it is gone. owned: HashMap>>, next_owner: u64, + /// Raised by [`Pool::discard`]: a texture taken before it never comes back to the pool. + generation: u64, } /// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. @@ -276,7 +280,10 @@ impl Pool { } } - fn release_owned(&mut self, t: wgpu::Texture, owner: u64) { + fn release_owned(&mut self, t: wgpu::Texture, owner: u64, generation: u64) { + if generation != self.generation { + return; + } if let Some(m) = self.owned.get_mut(&owner) { m.entry((t.width(), t.height())).or_default().push(t); return; @@ -306,6 +313,18 @@ impl Pool { self.free.clear(); self.bytes = 0; } + + /// After a failed frame (#1319): drop every kept texture, the free ones and those waiting + /// for an open encoder or kept by one, and the textures still out never return. The + /// encoders' bookkeeping stays, so reuse remains ordered. + fn discard(&mut self) { + self.clear(); + self.released.clear(); + for kept in self.owned.values_mut() { + kept.clear(); + } + self.generation = self.generation.wrapping_add(1); + } } /// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`). @@ -1015,8 +1034,9 @@ impl GpuContext { } fn texture(&self, w: u32, h: u32) -> wgpu::Texture { + // Named with its size, so a GPU error about it says which texture failed (#1319). self.device.create_texture(&wgpu::TextureDescriptor { - label: None, + label: Some(&format!("working image {w}×{h}")), size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, @@ -1031,10 +1051,10 @@ impl GpuContext { /// one is zeroed. fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h))); + let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX)); let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); - let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, generation }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -1150,6 +1170,21 @@ impl GpuContext { s.clear(); } } + + /// After a failed frame: forget every texture and buffer kept for reuse, since any of them + /// may be one the device rejected (#1319). Later frames allocate afresh. + pub fn discard_kept(&self) { + self.clear_uploads(); + if let Ok(mut p) = self.pool.lock() { + p.discard(); + } + if let Ok(mut z) = self.zeros.lock() { + z.clear(); + } + if let Ok(mut c) = self.particle_states.lock() { + c.clear(); + } + } } /// Records GPU work for one render (a command encoder submitted at readback or the end). diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 94b95166a..c58ccd59e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -97,6 +97,29 @@ pub use wgpu; use crate::context::Enc; +/// Why [`Gpu::within_memory`] discarded a frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrameError { + /// The GPU context is retired (device lost, a readback timed out, an uncaptured error). + Retired(String), + /// Out of video memory (or a driver reporting a failed allocation as an internal error), + /// with any validation errors it went on to cause. + Memory(String), + /// The device rejected a command with no allocation failure before it (an invalid texture + /// or parameters, #1319): a compositor bug, not memory pressure. + Validation(String), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::Retired(e) | FrameError::Memory(e) | FrameError::Validation(e) => f.write_str(e), + } + } +} + +impl std::error::Error for FrameError {} + /// The GPU compositor (cheap to clone; one device shared by all clones). #[derive(Clone)] pub struct Gpu { @@ -265,33 +288,42 @@ impl Gpu { } } - /// Run work in native thread-local out-of-memory and internal-error scopes. A failed - /// result is discarded and cached allocations are released; callers can retry with a fresh - /// CPU renderer. Some drivers report a failed allocation as an internal error rather than - /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. + /// Run work in native thread-local out-of-memory, internal-error and validation scopes. A + /// failed result is discarded and the textures and buffers kept for reuse are released; + /// callers can retry with a fresh CPU renderer. Some drivers report a failed allocation as an + /// internal error rather than out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), + /// so both count. A failed allocation leaves an invalid texture whose use is then a + /// validation error (`Texture::create_view`, #1319): caught here, it fails this frame only + /// instead of retiring the GPU compositor for the session as an uncaptured error. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. - pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { - self.ctx.check_health()?; + pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { + self.ctx.check_health().map_err(FrameError::Retired)?; #[cfg(not(target_arch = "wasm32"))] { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); + let validation = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); let out = f(); + let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); - match internal.or(oom) { - Some(e) => { - self.ctx.clear_uploads(); - Err(e.to_string()) - } - None => self.ctx.check_health().map(|()| out), + let memory = oom.is_some() || internal.is_some(); + // The allocation failure first: a validation error is often its consequence. + let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); + if errors.is_empty() { + return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired); } + // Its textures go back to the pool when dropped: drop them before emptying it. + drop(out); + self.ctx.discard_kept(); + let errors = errors.join("; then: "); + Err(if memory { FrameError::Memory(errors) } else { FrameError::Validation(errors) }) } #[cfg(target_arch = "wasm32")] { let out = f(); - self.ctx.check_health().map(|()| out) + self.ctx.check_health().map(|()| out).map_err(FrameError::Retired) } } diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 7934dee0c..9ac103557 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -23,16 +23,38 @@ struct Checkpoint { key: u64, steps: u64, n: u32, + /// The birth steps of its particles (see [`born`]). + born: u64, buf: wgpu::Buffer, used: u64, } +/// The particles born in the first `steps` steps of `births` (ascending birth steps) and a hash +/// of their birth steps: a checkpoint is only resumed from when the request's births before it +/// are the same (a keyframed Birth Rate isn't part of the system key, #1373). +fn born(births: &[u32], steps: u64) -> (u32, u64) { + let mut h = 0xcbf2_9ce4_8422_2325u64; + let mut n = 0u32; + for &b in births.iter().take_while(|&&b| u64::from(b) < steps) { + h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3); + n += 1; + } + (n, h) +} + #[derive(Default)] pub(crate) struct Checkpoints { list: Vec, clock: u64, } +impl Checkpoints { + /// Forget every kept state (after a failed frame, whose buffers may be invalid). + pub(crate) fn clear(&mut self) { + self.list.clear(); + } +} + fn pipes(g: &GpuContext) -> &Pipes { g.particles.get_or_init(|| { let d = &g.device; @@ -106,6 +128,10 @@ fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { out } +/// Simulations dispatched on the GPU (tests check the GPU path really ran). +#[cfg(test)] +pub(crate) static DISPATCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). /// /// With deferred readbacks (a browser worker) the state's readback is keyed by the request @@ -141,7 +167,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option { k.used = now; (k.steps, Some(k.buf.clone()), k.n) @@ -187,6 +213,8 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option CHECKPOINTS { let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; c.list.remove(i); diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..182868423 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2 { + let h = tap_hash(x, y); + return vec2(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5); +} + +// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice +// cell (cx, cy), blended bilinearly by the receiver's position w in the cell. +fn tap_jitter(cx: i32, cy: i32, w: vec2, i: i32, j: i32) -> vec2 { + let x = cx + i * 7919; + let y = cy + j * 7919; + let c00 = tap_jitter_at(x, y); + let c10 = tap_jitter_at(x + 1, y); + let c01 = tap_jitter_at(x, y + 1); + let c11 = tap_jitter_at(x + 1, y + 1); + let a = c00 + (c10 - c00) * w.x; + let b = c01 + (c11 - c01) * w.x; + return a + (b - a) * w.y; +} + const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { // shadows off their casters), each texel compared against the receiver plane's // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid // jittered by an integer hash (bit-identical across backends) of the sub-texel - // cell and tap index, and bilinear compares of the four nearest texel centres. + // lattice and tap index, blended across the receiver's lattice cell, and bilinear + // compares of the four nearest texel centers. // extra.y is the wanted penumbra in output pixels; dividing by the receiver's // texel footprint keeps it in output pixels at any map resolution, and the reach // is at least two texels so hard edges ramp over texel steps instead of showing them. @@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); let step = reach / f32(n - 1); let half = f32(n - 1) * 0.5; - let cx = i32(floor(u * 8.0)); - let cy = i32(floor(v * 8.0)); + let gu = u * 8.0; + let gv = v * 8.0; + let cx = i32(floor(gu)); + let cy = i32(floor(gv)); + let wc = vec2(gu - floor(gu), gv - floor(gv)); var lit = 0.0; for (var j = 0; j < n; j = j + 1) { for (var i = 0; i < n; i = i + 1) { - let h = tap_hash(cx + i * 7919, cy + j * 7919); - let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let su = u + ((f32(i) - half) + jx) * step; - let sv = v + ((f32(j) - half) + jy) * step; + let jit = tap_jitter(cx, cy, wc, i, j); + let su = u + ((f32(i) - half) + jit.x) * step; + let sv = v + ((f32(j) - half) + jit.y) * step; // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. let tu = su - 0.5; diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl index 1c963385a..41996af36 100644 --- a/crates/gpu/src/shaders/classic3d.wgsl +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -199,6 +199,12 @@ fn attenuation(l: u32, p: vec3) -> f32 { return k; } +// compose::shadow_taps: samples per side of the Shadow Diffusion kernel. +fn shadow_taps(r_px: f32) -> u32 { + let n = clamp(u32(ceil(2.0 * r_px / 1.5)), 3u, 15u); + return n | 1u; +} + // compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { let pl = planes[c]; @@ -243,9 +249,14 @@ fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { if (r * pl.tex.z < 0.5) { px = texel(c, hit.x, hit.y); } else { - for (var j = -1; j <= 1; j++) { - for (var i = -1; i <= 1; i++) { - px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0; + // compose::shadow_taps: n × n box filter over the penumbra square. + let n = shadow_taps(r * pl.tex.z); + let step = 2.0 * r / f32(n); + let h = i32(n / 2u); + let w = 1.0 / f32(n * n); + for (var j = -h; j <= h; j++) { + for (var i = -h; i <= h; i++) { + px += texel(c, hit.x + f32(i) * step, hit.y + f32(j) * step) * w; } } } diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl index 5fbc76b4b..5e9e20a12 100644 --- a/crates/gpu/src/shaders/fx_distort.wgsl +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -238,7 +238,7 @@ fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 { if (o + 1u == n && frac > 0.0) { w = frac; } - sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; + sum += value_noise_linear_z(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; norm += amp * w; amp *= falloff; f *= 2.0; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index a63a91fed..2c6f5e50c 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -647,7 +647,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3) { // stylize3::hex_cell (cube rounding). let q = (0.5773502691896257 * u - v / 3.0) / r; let rr = (2.0 / 3.0 * v) / r; - let ccy = -q - rr; + // Evaluate the third cube coordinate symmetrically with q so the centerline + // tie is not broken by subtracting separately rounded coordinates. + let ccy = (-0.5773502691896257 * u - v / 3.0) / r; var rx = round_away(q); let ry = round_away(ccy); var rz = round_away(rr); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 834c5f528..bb4a5e63a 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -525,12 +525,25 @@ fn fade(t: f32) -> f32 { } fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + return value_noise_z(x, y, z, seed, true); +} + +// effects::generate::value_noise_linear_z: z (evolution) interpolates linearly, x and y keep the +// quintic fade (Turbulent Displace, #943). +fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 { + return value_noise_z(x, y, z, seed, false); +} + +fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 { let x0 = floor(x); let y0 = floor(y); let z0 = floor(z); let fx = fade(x - x0); let fy = fade(y - y0); - let fz = fade(z - z0); + var fz = z - z0; + if (ease_z) { + fz = fade(fz); + } let ix = i32(x0); let iy = i32(y0); let iz = i32(z0); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 4b3375411..0aaf38af0 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -864,6 +864,71 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } +/// A texture the device rejected (deliberately too wide), made outside any frame under a scope +/// of its own: the view a frame then makes of it is the reported validation error. +fn rejected_texture(g: &Gpu) -> wgpu::Texture { + let scope = g.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1); + let t = g.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("deliberately too wide"), + size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + assert!(pollster::block_on(scope.pop()).is_some(), "the device rejected the texture"); + t +} + +/// A frame that makes a view of an invalid texture ("Texture with '' label is invalid", #1319) +/// fails alone: the validation error doesn't reach the device's handler (which retires the +/// compositor for the session, and panics in tests), and the next frame composites on the GPU, +/// read back, like the CPU. +#[test] +fn a_view_of_an_invalid_texture_fails_the_frame_not_the_compositor() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let err = g.within_memory(|| rejected.create_view(&Default::default())).expect_err("the frame fails"); + assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}"); + assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); + let s = blend_scene(BitDepth::Bpc32, BlendMode::Normal); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.within_memory(|| g.render(&r, s.cid, Tick::ZERO)).expect("the next frame isn't rejected").expect("the GPU renders it"); + let mut d = diff(&cpu, &img, tolerance(BitDepth::Bpc32)); + d.quantized = false; + check("after a rejected frame", Some(d), 0.0); +} + +/// No texture of a failed frame is reused: not one released while another encoder is still +/// open (it waits for that encoder), nor one taken before the failure and handed back after. +#[test] +fn a_failed_frame_returns_no_texture_to_the_pool() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame"); + let mut other = crate::context::Enc::new(&g.ctx); + other.encoder(); + g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails"); + drop(earlier); + drop(other); + let (hits, _) = g.ctx.pool_stats(); + let (a, b) = g + .within_memory(|| { + let mut e = crate::context::Enc::new(&g.ctx); + (e.image(64, 32), e.image(48, 16)) + }) + .expect("the next frame"); + assert_eq!(((a.width, a.height), (b.width, b.height)), ((64, 32), (48, 16))); + assert_eq!(g.ctx.pool_stats().0, hits, "no texture of the failed frame was reused"); +} + /// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an /// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of /// panicking in `create_buffer_init`'s mapped range on a frame thread (#591). diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..f09798119 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -96,7 +96,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) { pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { let r = Renderer::new(p, &NoFootage, RenderOpts::default()); - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) } @@ -277,7 +286,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) { } pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) { - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) } diff --git a/crates/gpu/src/tests/fx_distort.rs b/crates/gpu/src/tests/fx_distort.rs index 19cdcd605..1b8621408 100644 --- a/crates/gpu/src/tests/fx_distort.rs +++ b/crates/gpu/src/tests/fx_distort.rs @@ -55,6 +55,11 @@ fn turbulent_displace_and_cc_lens() { ], ); effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]); + // #943: Evolution moves the pattern at a constant rate on both paths, including steps of a + // few degrees and angles past a whole revolution, where an eased evolution axis diverges. + for evolution in [10.0, 37.0, 400.0, 725.5] { + effect_case("ec.distort.turbulentdisplace", &[("evolution", n(evolution)), ("size", n(12.0)), ("amount", n(30.0))]); + } effect_case("ec.distort.cclens", &[("size", n(70.0))]); effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]); effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); diff --git a/crates/gpu/src/tests/particles.rs b/crates/gpu/src/tests/particles.rs index d2a229b0f..e11dd6585 100644 --- a/crates/gpu/src/tests/particles.rs +++ b/crates/gpu/src/tests/particles.rs @@ -91,6 +91,79 @@ fn particle_world_matches_the_cpu() { } } +/// A host whose Birth Rate is `rate(layer time)` (#1373), with the GPU backend or without (the +/// CPU oracle). +struct Keyed { + gpu: Option<&'static Gpu>, + rate: fn(f64) -> f64, +} + +impl EffectHost for Keyed { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + self.gpu.map(|g| g as &dyn ParticleSim) + } + fn param_at(&self, _effect_index: usize, id: &str, t: f64) -> Option { + (id == "birthRate").then(|| n((self.rate)(t))) + } +} + +/// Effect `id`'s live particles at layer time `t` through `host`. +fn keyed_state(id: &str, p: &Params, t: f64, host: &Keyed) -> Vec { + let env = EffectEnv { host: Some(host), ..Default::default() }; + let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + effectcraft_effects::particle_state(id, &ctx).unwrap() +} + +/// 3 a second from 0 to 0.5 s. +fn early(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// 3 a second from 0.25 to 0.75 s: as many births by 0.9 s as [`early`], at other steps. +fn late(t: f64) -> f64 { + if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 } +} + +/// Effect `id` (parameters `p`) under the [`early`] then the [`late`] Birth Rate history: the +/// GPU's particles match the CPU's, and each really came from the GPU. +fn keyed_agree(id: &str, p: &Params, g: &'static Gpu) { + for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { + let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); + for t in times { + let cpu = keyed_state(id, p, t, &cpu_host); + let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed); + let gpu = keyed_state(id, p, t, &gpu_host); + assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "{id} t {t}: simulated on the GPU"); + agree(&format!("{id} keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); + } + } +} + +/// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU +/// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one +/// history is not resumed for another with the same system key, even when both have born as +/// many particles (180 by 0.9 s here) at other steps. +#[test] +fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]); + keyed_agree("ec.sim.ccparticleworld", &p, g); +} + +/// The same for CC Particle Systems II (90 births by 0.9 s in both histories). +#[test] +fn particle_systems_ii_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let p = params("ec.sim.ccparticlesystems2", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("randomSeed", n(22.0))]); + keyed_agree("ec.sim.ccparticlesystems2", &p, g); +} + #[test] fn particle_systems_ii_matches_the_cpu() { let Some(g) = gpu() else { return }; diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index 32f11a341..a9c2e85e2 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -133,6 +133,22 @@ fn lights_specular_transmission_and_shadows() { } } +#[test] +fn wide_shadow_diffusion() { + // #2444: a wide penumbra takes more than the 3 × 3 kernel's samples; both sides pick the + // same count. + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.switches.three_d { + set(l, "materialOptions/castsShadows", Value::Enum(1)); + } + } + light(&mut s, LightKind::Point, [150.0, 20.0, -80.0], &[("castsShadows", Value::Bool(true)), ("shadowDiffusion", n(60.0))]); + check(&format!("wide shadow diffusion {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + #[test] fn depth_of_field_and_motion_blur() { for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 78a6857fb..d4d7a62a1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut i = j; } else { match r.collapsed(ctx, visible[i]) { - Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?, + Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?, None => draw_layer(e, r, ctx, visible[i], canvas)?, } i += 1; @@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend Some(cv) } -fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { +fn draw_collapsed<'a>( + e: &mut Enc, + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + collapsed: &effectcraft_render::Collapsed, + canvas: &mut GpuImage, +) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { return Some(()); } let matte = r.track_matte(ctx, layer).is_some(); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) { draw_comp(e, &sub, &nctx, canvas)?; } return Some(()); } - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) }; let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) diff --git a/crates/hevcenc/tests/roundtrip.rs b/crates/hevcenc/tests/roundtrip.rs index 777bc455b..7591517e9 100644 --- a/crates/hevcenc/tests/roundtrip.rs +++ b/crates/hevcenc/tests/roundtrip.rs @@ -132,12 +132,36 @@ fn ffmpeg_available() -> bool { Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false) } +/// `true` when this ffmpeg/ffprobe build actually ships an HEVC decoder. Distro "free" builds +/// (e.g. Fedora's ffmpeg-free) ship without one — the tools run but cannot decode our stream, so +/// the external oracle means nothing and the test skips rather than failing on a gap in the tool. +fn hevc_decoder_available() -> bool { + Command::new("ffmpeg") + .args(["-hide_banner", "-decoders"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout).lines().any(|l| { + // Decoder rows look like ` V....D hevc ...`; match the codec name, not the text. + // Every row of `-decoders` is a decoder, so the flags column says nothing about + // that — its trailing `D` is "supports direct rendering method 1", which a + // build's hevc decoder may well lack (requiring it would skip a working oracle). + let mut it = l.split_whitespace(); + it.next().is_some() && it.next() == Some("hevc") + }) + }) + .unwrap_or(false) +} + /// Decode the Annex-B stream with ffmpeg and require bit-exact equality and an empty stderr. fn check_ffmpeg(run: &Run, w: usize, h: usize, bd: u32, tag: &str) { if !ffmpeg_available() { eprintln!("ffmpeg not found; skipping the ffmpeg oracle"); return; } + if !hevc_decoder_available() { + eprintln!("ffmpeg has no HEVC decoder here; skipping the ffmpeg oracle"); + return; + } let dir = std::env::temp_dir(); let uniq = format!("{}-{}-{}", tag, std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); let inp = dir.join(format!("hevcenc-{uniq}.hevc")); @@ -197,6 +221,10 @@ fn ffprobe_stream_info() { if !ffmpeg_available() { return; } + if !hevc_decoder_available() { + eprintln!("ffmpeg has no HEVC decoder here; ffprobe cannot fully describe our stream, skipping"); + return; + } let (w, h) = (130, 72); let pics: Vec = (0..2).map(|i| synth(w, h, 10, i, 0)).collect(); let mut c = cfg(w as u32, h as u32, 30, 30, Profile::Main10); diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index a73f15937..3114c2ddb 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() { let v3 = |s: &effectcraft_engine::Session, path: &str| { let comp = s.project.comp(left).unwrap(); let layer = &comp.layers[0]; - let ctx = - effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); diff --git a/crates/host/tests/it/background_presets.rs b/crates/host/tests/it/background_presets.rs index e3e44b009..c03ec0066 100644 --- a/crates/host/tests/it/background_presets.rs +++ b/crates/host/tests/it/background_presets.rs @@ -58,3 +58,34 @@ fn every_background_preset_renders_moves_and_undoes() { assert_eq!(fx, 0, "{id}: undo removes the effects"); } } + +/// #1510: each Behaviors preset keeps its layer moving (position, scale, rotation or opacity +/// changes over time) and renders finite, deterministic frames. +#[test] +fn every_behavior_preset_renders_and_moves() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "BH", "width": 160, "height": 90, "frameRate": 30, "duration": 8})).unwrap(); + // A small white square in the middle so position, scale and rotation changes are visible. + let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + for p in &list { + let id = p["id"].as_str().unwrap(); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}"); + let mut frames = Vec::new(); + // Times chosen off the periods of every preset so no two samples coincide. + for t in [0.03, 0.13, 0.41, 0.97, 1.73] { + let (px, _, _, nan) = frame(&s, t); + assert!(!nan, "{id} @ {t}: NaN or infinite pixel"); + let (again, ..) = frame(&s, t); + assert!(px == again, "{id} @ {t}: render is not deterministic"); + frames.push(px); + } + let moved = (1..frames.len()).any(|i| diff(&frames[0], &frames[i]) > 20.0); + assert!(moved, "{id}: the layer does not move ({:?})", (1..frames.len()).map(|i| diff(&frames[0], &frames[i])).collect::>()); + s.execute("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 73fc04841..826363edb 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -6,9 +6,11 @@ mod determinism; mod exr_layers; mod footage_reload; mod gif_animates; +mod pick_whip; mod plugins; mod relink; mod tga_import; mod timeline_interop; mod vector_import; +mod wav_markers; mod webm_metadata; diff --git a/crates/host/tests/it/pick_whip.rs b/crates/host/tests/it/pick_whip.rs new file mode 100644 index 000000000..340cda053 --- /dev/null +++ b/crates/host/tests/it/pick_whip.rs @@ -0,0 +1,38 @@ +//! The pick whip's references read the property they were made from (#1072): an Alt pick whip's +//! (`byIndex`) select an effect's properties by index, `effect("Fractal Noise")(i)(j)`, and must +//! read the same value as the reference by name. +//! +//! (With Compact English off, a reference to another layer, `thisComp.layer("Other")("effects")…`, +//! doesn't evaluate yet, by name or by index: "not a callable function".) + +use serde_json::{Value, json}; + +#[test] +fn index_references_read_the_same_values_as_names() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Reader", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + s.execute("prop.set", json!({"layer": o, "path": "effects/#1/transform/scale", "value": 77})).unwrap(); + s.execute("prop.set", json!({"layer": o, "path": "effects/#2/point", "value": [12, 34]})).unwrap(); + let evaluated = |s: &mut effectcraft_engine::Session, path: &str| -> Value { + let v = s.execute("prop.get", json!({"layer": l, "path": path})).unwrap(); + assert!(v.get("expressionError").is_none(), "{v}"); + v["evaluated"].clone() + }; + for (path, target, dimension, want) in + [("transform/rotation", "effects/#1/transform/scale", None, json!(77.0)), ("transform/opacity", "effects/#2/point", Some(1), json!(34.0))] + { + let mut t = json!({"layer": o, "path": target}); + if let Some(d) = dimension { + t["dimension"] = json!(d); + } + for by_index in [false, true] { + let e = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": t, "byIndex": by_index})).unwrap(); + assert_eq!(evaluated(&mut s, path), want, "by index {by_index}: {e}"); + } + } +} diff --git a/crates/host/tests/it/wav_markers.rs b/crates/host/tests/it/wav_markers.rs new file mode 100644 index 000000000..98d2685dc --- /dev/null +++ b/crates/host/tests/it/wav_markers.rs @@ -0,0 +1,126 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. + +use effectcraft_engine::Session; +use effectcraft_project::LayerSource; +use serde_json::json; + +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + +/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a +/// `LIST` `adtl`). +fn marker_wav(points: &[(u32, u32, &str)]) -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for &(id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the +/// import is available to Update Markers From Source (and a layer already made keeps its own). +#[test] +fn reloading_a_wav_picks_up_cue_points_added_to_the_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let (layer, item) = s + .active_comp() + .unwrap() + .layers + .iter() + .find_map(|l| match l.source { + LayerSource::Footage { item } => Some((l.id.0, item.0)), + _ => None, + }) + .unwrap(); + assert_eq!(names(&mut s, layer), ["Intro"]); + // The file gains a cue point; the layer does not change until asked. + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap(); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap(); + assert_eq!(r["reloaded"], 1, "{r}"); + assert_eq!(names(&mut s, layer), ["Intro"]); + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Added later"]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 55161b718..798ca97bf 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -143,6 +143,11 @@ pub fn import( if depth > MAX_DEPTH { return Err(Error::TooDeep(MAX_DEPTH)); } + // FilmCraft's parser expands internal entities without a node limit (#2129); Final Cut Pro + // and FCPXML exports never declare any. + if matches!(format, TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml) && bytes.windows(8).any(|w| w == b" = std::result::Result; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..1e0c7d417 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -520,6 +520,21 @@ fn export_survives_a_precomp_layer_pointing_at_a_non_comp() { assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_)))); } +#[test] +fn xml_timelines_declaring_entities_are_refused() { + // #2129: the timeline parser expands internal entities without a node limit. + let dtd = "]>"; + let docs = [ + (TimelineFormat::Fcp7Xml, format!("{dtd}&b;")), + (TimelineFormat::Fcpxml, format!("{dtd}&b;")), + ]; + for (format, doc) in docs { + let mut p = Project::default(); + let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None); + assert!(matches!(r, Err(Error::XmlEntities)), "{format:?}"); + } +} + #[test] fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { // A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack. diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index b2eeece49..6d7cc1f5a 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -47,7 +47,9 @@ fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> { fn hex_color(sc: &str) -> [f32; 3] { let h = sc.trim_start_matches('#'); - let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0; + let c = |i: usize| { + h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0 + }; [c(0), c(2), c(4)] } @@ -141,7 +143,9 @@ pub fn import( let Some(id) = s(a, "id") else { continue }; if a.get("layers").is_some() { let folder = cx.folder(project); - let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h)); + let layer = precomp_layer_size(&doc, &assets, id); + let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); + let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate); let c = Comp::new(aw, ah, arate, duration); let nm = s(a, "nm").unwrap_or(id).to_string(); @@ -240,6 +244,19 @@ pub fn import( Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings }) } +/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`. +/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect. +fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> { + let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers"))); + lists.filter_map(Json::as_array).flatten().find_map(|l| { + if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) { + return None; + } + let (lw, lh) = f(l, "w").zip(f(l, "h"))?; + Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)) + }) +} + fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option| { if let (Some(j), Some(p)) = (j, tr.get_mut(m)) { @@ -399,7 +416,14 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ l.in_point = tb.tick(ip); l.out_point = tb.tick(op.max(ip)); l.start_time = tb.tick(f(j, "st").unwrap_or(0.0)); - l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0; + // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. + let sr = f(j, "sr").unwrap_or(1.0); + l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { + sr * 100.0 + } else { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); + 100.0 + }; if j.get("ddd").and_then(Json::as_u64) == Some(1) { l.switches.three_d = true; } @@ -485,3 +509,15 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ } } } + +#[cfg(test)] +mod hex_color_tests { + use super::hex_color; + + #[test] + fn hex_color_rejects_plus_sign() { + assert_eq!(hex_color("#+f0000"), [0.0, 0.0, 0.0]); + assert_eq!(hex_color("#zz0000"), [0.0, 0.0, 0.0]); + assert_eq!(hex_color("#ff0000"), [1.0, 0.0, 0.0]); + } +} diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 1283a2497..990a0f28b 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -3,8 +3,8 @@ use effectcraft_color::BlendMode; use effectcraft_keyframe::{Gradient, ShapePath, Value}; -use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{Node, PropGroup}; +use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; +use effectcraft_project::{Node, ParamUi, PropGroup}; use serde_json::{Map, Value as Json, json}; use crate::anim::{self, Ex, Im, export_prop, import_prop}; @@ -356,7 +356,7 @@ fn import_blend(j: &Json, g: &mut PropGroup) { } } -fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) { +fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) { set(im, g, "width", j.get("w"), &anim::to_scalar); if let Some(lc) = int(j, "lc") { set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32); @@ -374,17 +374,32 @@ fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) { if let Some(list) = j.get("d").and_then(Json::as_array) && let Some(d) = g.sub_mut("dashes") { - let mut seen_dash = false; + // The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does. + let (mut dashes, mut gaps) = (0, 0); for e in list { - let m = match e.get("n").and_then(Json::as_str) { - Some("d") if !seen_dash => { - seen_dash = true; - "dash" + let (n, pair) = match e.get("n").and_then(Json::as_str) { + Some("d") => (&mut dashes, 0), + Some("g") => (&mut gaps, 1), + Some("o") => { + set(im, d, "offset", e.get("v"), &anim::to_scalar); + continue; } - Some("g") => "gap", - Some("o") => "offset", _ => continue, }; + let k = *n; + *n += 1; + let m = match k { + 0 => ["dash", "gap"][pair], + _ => { + let Some(&(dm, gm)) = EXTRA_DASHES.get(k - 1) else { continue }; + if d.get(dm).is_none() { + let at = d.children.iter().position(|c| c.match_id() == "offset").unwrap_or(d.children.len()); + d.children.insert(at, ids.prop(gm, &format!("Gap {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into()); + d.children.insert(at, ids.prop(dm, &format!("Dash {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into()); + } + [dm, gm][pair] + } + }; set(im, d, m, e.get("v"), &anim::to_scalar); } } @@ -474,7 +489,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec Vec bool { let last = name.rsplit('.').next().unwrap_or(name); last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") @@ -75,7 +79,9 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { } }; collect_meta(&img.attributes.other); - let n = w.checked_mul(h)?; + // Each channel is a display-window plane: a 60000² window over a 1×1 data window was a + // multi-gigabyte allocation per channel (#2141). + let n = w.checked_mul(h).filter(|n| *n <= MAX_DISPLAY_PIXELS)?; // The decoded image is consumed channel by channel, so each channel's samples are freed (or // moved) as its plane is made instead of the whole file being held twice (#474). for layer in img.layer_data { @@ -337,6 +343,18 @@ mod tests { assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); } + /// #2141: one pixel of data in a 60000² display window is refused, not allocated per channel. + #[test] + fn a_huge_display_window_is_refused_not_allocated() { + use exr::prelude::*; + let channels = AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5]))].into()); + let mut image = Image::from_layer(Layer::new((1, 1), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + image.attributes.display_window = IntegerBounds::new((0, 0), (60000, 60000)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + assert!(read_exr_channels(&bytes.into_inner()).is_none()); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 055c3feb0..b259c8b7f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -113,6 +113,11 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - if is_svg(path, bytes) || is_pdf_like(path, bytes) { let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); + // A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136): + // past what Continuously Rasterize can draw, the footage reports an error instead. + if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) { + return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})"))); + } let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. let _ = op; @@ -121,10 +126,32 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } +/// The largest side vector footage is drawn at, decoded or continuously rasterised. +const MAX_VECTOR_SIDE: u32 = 16384; + /// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { let doc = vector_doc(path, bytes, layer, page)?.ok()?; let (w, h) = doc.pixel_size(); - let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); + let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// #2136: an SVG that declares 30000×30000 asked for a 14 GB image and aborted the app; it + /// is now an error, and a drawable size still decodes. + #[test] + fn vector_footage_past_the_drawable_size_is_an_error_not_an_abort() { + let huge = br#""#; + let footage = probe("huge.svg", huge).unwrap().unwrap(); + let e = decode("huge.svg", huge, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("30000×30000"), "{e}"); + let small = br#""#; + let footage = probe("small.svg", small).unwrap().unwrap(); + let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap(); + assert_eq!((img.width, img.height), (40, 20)); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 9b5b491ab..946e0d12a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; @@ -79,6 +80,22 @@ pub fn is_importable(path: &std::path::Path) -> bool { || dpx::EXTENSIONS.contains(&ext.as_str()) } +/// A whole file's bytes, refusing anything that isn't a regular file: a project can name +/// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). +pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + pub(crate) fn ext_of(path: &std::path::Path) -> String { path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } @@ -99,3 +116,20 @@ pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option effectcraft_time::FrameRate { if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 } } + +#[cfg(test)] +mod read_tests { + /// #2134: a folder (and on Unix a device) named as footage is an error, not an endless read. + #[test] + fn only_regular_files_are_read_whole() { + let dir = std::env::temp_dir(); + assert!(super::read_regular_file(&dir).is_err()); + assert!(crate::probe::probe(&dir).is_err(), "probing a folder fails instead of reading it"); + #[cfg(unix)] + assert!(super::read_regular_file(std::path::Path::new("/dev/zero")).is_err()); + let f = dir.join(format!("ec-read-regular-{}.bin", std::process::id())); + std::fs::write(&f, b"abc").unwrap(); + assert_eq!(super::read_regular_file(&f).unwrap(), b"abc"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 4820efa35..1f1587059 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -67,6 +67,10 @@ const CONFORM_VERSION: u32 = 3; /// conform thread allocate all of it at once (#408). const MAX_CONFORM_SECONDS: i64 = 24 * 3600; +/// The sample rates audio is read at; a header outside them is damaged or hostile (#2141). +const MIN_SAMPLE_RATE: u32 = 1_000; +const MAX_SAMPLE_RATE: u32 = 768_000; + /// The sample frames of `footage`'s audio at `rate` Hz that a conformed file holds, `None` past /// [`MAX_CONFORM_SECONDS`]. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -513,6 +517,11 @@ impl MediaPool { let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + // The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about + // 47 GB (#2141). Real audio is 1–768 kHz. + if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) { + return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); + } if native == 0 || rate == 0 || native == rate { return read_stereo(&src, s0, frames, rate); } @@ -581,7 +590,7 @@ impl Inner { if let Some(b) = lock(&self.files).get(path) { return Ok(b.clone()); } - std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) + crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } /// The opened movie/audio source for `path`. The first caller opens it, outside the map's @@ -1218,8 +1227,8 @@ mod tests { assert_eq!(px(&f, FloatCurve::Gamma24), g24); } - /// A model's material library is read beside it; one named by a path to another computer - /// is not read. + /// A model's material library is read beside it; one named by a path to another computer is + /// looked up under its own file name beside the model, never fetched from that machine (#1274). #[test] fn model_files_on_other_computers_are_not_read() { let dir = std::env::temp_dir().join(format!("ec-model-files-{}", std::process::id())); @@ -1233,11 +1242,13 @@ mod tests { model.materials.iter().any(|m| m.base_color[..3] == [1.0, 0.0, 0.0]) }; assert!(red("beside.obj", "red.mtl")); - assert!(!red("unc.obj", r"\\192.0.2.1\share\red.mtl")); - assert!(!red("url.obj", "file://192.0.2.1/s/red.mtl")); + // 192.0.2.1 is a test-net address nothing here can reach, so these passes prove the + // material library came from beside the model, not from another computer. + assert!(red("unc.obj", r"\\192.0.2.1\share\red.mtl")); + assert!(red("url.obj", "file://192.0.2.1/s/red.mtl")); // Linux reads `//dir/red.mtl` as the local file. #[cfg(unix)] - assert!(!red("slashes.obj", &format!("/{}", dir.join("red.mtl").display()))); + assert!(red("slashes.obj", &format!("/{}", dir.join("red.mtl").display()))); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bba682b3..f695ff0d0 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -25,7 +25,7 @@ pub fn probe(path: impl AsRef) -> Result { correct_rate(&mut f, header.as_deref().and_then(video_rate)); return Ok(f); } - let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; + let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path)); } @@ -38,7 +38,7 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf()); move |uri| { let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?; - std::fs::read(p).ok() + crate::read_regular_file(&p).ok() } } @@ -107,8 +107,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { 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 rate = video_rate(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); Ok(f) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..ca40824b6 --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,517 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; + let start = at.saturating_add(8); + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); + for m in xmp_markers(xmp, rate) { + if seen.insert((m.comment.clone(), m.time)) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { + match &id { + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); + } + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } +} diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index a2849dc1d..4d539c4e5 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -24,9 +24,40 @@ fn have_ffmpeg() -> bool { *HAVE.get_or_init(|| Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success())) } +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free" +/// builds often omit `libx264` (the one named historically here) but ship an alternative, so pick +/// the first present by name rather than hard-coding one that may be absent. These tests only +/// need *an* H.264 encoder to make an oracle fixture; the encoder identity is irrelevant. +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + /// Run ffmpeg to produce `name` (once; written to a temp name and renamed so concurrent test /// processes never see a partial file). `None` when ffmpeg is unavailable. fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { + ffmpeg_fixture_inner(name, args, true) +} + +/// Like [`ffmpeg_fixture`] for a fixture this build of ffmpeg may fail to make: an encoder found +/// in the `-encoders` listing can still not run here (the hardware backends need their device, +/// Fedora's `libopenh264` is the `noopenh264` stub), and fixtures built from one, like +/// [`h264_mp4`], should skip with a note rather than fail on the packager's choice of ffmpeg — +/// the same way every test here skips when ffmpeg is missing altogether. +fn try_ffmpeg_fixture(name: &str, args: &[&str]) -> Option { + ffmpeg_fixture_inner(name, args, false) +} + +fn ffmpeg_fixture_inner(name: &str, args: &[&str], must_succeed: bool) -> Option { if !have_ffmpeg() { eprintln!("ffmpeg not found: skipping"); return None; @@ -39,7 +70,14 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { let ext = out.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], "")); let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg"); - assert!(st.success(), "ffmpeg failed for {name}"); + if !st.success() { + let _ = std::fs::remove_file(&tmp); + if must_succeed { + panic!("ffmpeg failed for {name}"); + } + eprintln!("this ffmpeg build cannot make {name}: skipping"); + return None; + } if std::fs::rename(&tmp, &out).is_err() { // Another test made it first; on Windows the rename fails while that test has it open. let _ = std::fs::remove_file(&tmp); @@ -48,39 +86,35 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { Some(out) } +/// Video options the chosen H.264 encoder actually accepts. `libx264` keeps its constant-quality +/// `-preset`/`-crf` pair; `libopenh264` rejects both (it takes a bitrate), and the hardware +/// backends take a bitrate too — without this, a build whose picked encoder is not libx264 failed +/// the fixture and silently skipped every check gated on it. +fn h264_video_options(enc: &str) -> Vec<&'static str> { + if enc == "libx264" { vec!["-preset", "medium", "-crf", "18"] } else { vec!["-b:v", "2M"] } +} + fn h264_mp4() -> Option { - ffmpeg_fixture( - "testsrc2_1080p.mp4", - &[ - "-f", - "lavfi", - "-i", - "testsrc2=size=1920x1080:rate=30:duration=2", - "-f", - "lavfi", - "-i", - "sine=frequency=440:sample_rate=48000:duration=2", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-preset", - "medium", - "-crf", - "18", - "-colorspace", - "bt709", - "-color_primaries", - "bt709", - "-color_trc", - "bt709", - "-c:a", - "aac", - "-ac", - "2", - "-shortest", - ], - ) + // Made with whatever encoder this build has listed: a listed encoder can still fail at run + // time (the hardware ones without their device), so this may skip rather than fail. + let enc = h264_encoder()?; + let mut args: Vec<&'static str> = vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=1920x1080:rate=30:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + enc, + "-pix_fmt", + "yuv420p", + ]; + args.extend(h264_video_options(enc)); + args.extend(["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-c:a", "aac", "-ac", "2", "-shortest"]); + try_ffmpeg_fixture("testsrc2_1080p.mp4", &args) } fn prores_mov() -> Option { @@ -281,7 +315,10 @@ fn h264_audio_samples() { /// H.264 + AAC at 44.1 kHz in MP4 (ffmpeg writes an edit list that skips the AAC priming): /// 440 Hz left, a 200 → 1000 Hz sweep right, 29.97 fps. fn aac_44k_mp4() -> Option { - ffmpeg_fixture( + // Made with whatever encoder this build has listed: a listed encoder can still fail at run + // time (the hardware ones without their device), so this may skip rather than fail. + let enc = h264_encoder()?; + try_ffmpeg_fixture( "aac_44k.mp4", &[ "-f", @@ -293,7 +330,7 @@ fn aac_44k_mp4() -> Option { "-i", "aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", - "libx264", + enc, "-pix_fmt", "yuv420p", "-c:a", @@ -1098,7 +1135,7 @@ fn a_called_off_read_does_not_decode() { /// 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( + let Some(p) = try_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 { @@ -1122,3 +1159,13 @@ fn an_unsupported_stream_reports_why_it_shows_no_picture() { pool.forget(&f.path); assert_eq!(pool.problem(&f), None); } + +#[test] +fn the_h264_fixture_options_fit_the_picked_encoder() { + assert_eq!(h264_video_options("libx264"), ["-preset", "medium", "-crf", "18"]); + for enc in ["libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv"] { + let options = h264_video_options(enc); + assert!(!options.iter().any(|o| *o == "-preset" || *o == "-crf"), "{enc} rejects preset/crf: {options:?}"); + assert!(options.contains(&"-b:v"), "{enc} gets a bitrate: {options:?}"); + } +} diff --git a/crates/media/tests/it/open_in_place.rs b/crates/media/tests/it/open_in_place.rs index 78b0abcbc..91eab2ba3 100644 --- a/crates/media/tests/it/open_in_place.rs +++ b/crates/media/tests/it/open_in_place.rs @@ -19,8 +19,26 @@ fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") } +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none (see the same +/// helper in `footage.rs`: distro "free" builds often omit `libx264` and ship an alternative). +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + /// 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. +/// processes never see a partial file). `None` without ffmpeg, or when this build cannot encode +/// H.264 at all — the test only measures how a movie is read, so it skips rather than asserting +/// on the packager's choice of ffmpeg. fn small_mp4() -> Option { let out = fixtures().join("in_place_small.mp4"); if out.exists() { @@ -29,16 +47,21 @@ fn small_mp4() -> Option { if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { return None; } + let enc = h264_encoder()?; 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"]) + .args(["-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) .arg(&tmp) .status() .unwrap(); - assert!(st.success(), "ffmpeg failed"); + if !st.success() { + let _ = std::fs::remove_file(&tmp); + eprintln!("this ffmpeg build cannot make in_place_small.mp4: skipping"); + return None; + } std::fs::rename(&tmp, &out).unwrap(); Some(out) } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 33ee26726..ebe7da7e8 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -14,5 +14,6 @@ effectcraft-geom = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +log = { workspace = true } image = { workspace = true } miniz_oxide = "0.9" diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index e17d014f6..c0b736359 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,7 +11,7 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. -use std::sync::Arc; +use std::{cell::Cell, sync::Arc}; use serde_json::Value; @@ -87,6 +87,8 @@ pub fn uri_decode(s: &str) -> String { while i < b.len() { if b[i] == b'%' && i + 2 < b.len() + && b[i + 1].is_ascii_hexdigit() + && b[i + 2].is_ascii_hexdigit() && let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) { out.push(v); @@ -112,6 +114,14 @@ fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option>) -> Result { j: &'a Value, buffers: Vec>, + /// Zero components that buffer-less accessors may still allocate, across the whole file: + /// nothing backs them, so many references to one capped accessor must not multiply (#2139). + zero_left: Cell, + /// Components every accessor read may still allocate, across the whole file: each primitive + /// that references an accessor keeps its own copy, so references must not multiply memory. + read_left: Cell, + /// The budget `read_left` started from, for the error message. + read_cap: usize, } fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { @@ -126,10 +136,18 @@ fn indices(v: &Value, k: &str) -> Vec { arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() } -/// The most components an accessor without a buffer view (all zeros) may have: nothing in the -/// file backs its count, so it is capped instead of checked against a buffer (#1235). +/// The most components accessors without a buffer view (all zeros) may have, together: nothing +/// in the file backs their counts, so they are capped instead of checked against a buffer +/// (#1235, #2139). const MAX_ZERO_COMPONENTS: usize = 1 << 24; +/// The most components all accessor reads in one file may allocate together (about 1 GiB as +/// `f32`/`u32`). Each primitive keeps its own copy of what it reads, so an accessor referenced +/// N times costs N times; this bounds that by a fixed amount rather than by buffer size, because +/// real assets legitimately share one vertex buffer across many primitives. A 10M-triangle scan +/// (5M vertices with position, normal, UV and tangent, 30M indices) reads about 90M. +const MAX_READ_COMPONENTS: usize = 1 << 28; + /// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` /// apart from `base`, all checked to be inside `buf`. struct Span<'b> { @@ -150,7 +168,30 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { out } +/// Bytes of component `c` of element `i`, with checked offsets (the span was validated, but +/// nothing derived from the file is indexed unchecked). +fn component(buf: &[u8], base: usize, stride: usize, i: usize, c: usize, csize: usize) -> Result<&[u8]> { + let o = i.checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(c.checked_mul(csize)?)); + o.and_then(|o| buf.get(o..o.checked_add(csize)?)).ok_or_else(|| perr("accessor reads past its buffer")) +} + impl Doc<'_> { + /// Charge `total` components to the file's read budget before allocating them. + fn charge(&self, total: usize) -> Result<()> { + let left = self.read_left.get(); + let Some(rest) = left.checked_sub(total) else { + let cap = self.read_cap; + return Err(perr(format!("accessor reads need more than {cap} components in total ({total} requested, {left} left)"))); + }; + self.read_left.set(rest); + Ok(()) + } + + /// Charge `count` items of `per` components each. + fn charge_each(&self, count: usize, per: usize) -> Result<()> { + self.charge(count.checked_mul(per).ok_or_else(|| perr("primitive is too large"))?) + } + /// The accessor's element count and total components (`count * n`), checked; and its span in /// the buffer, validated before anything is allocated from the count. `None` for an /// accessor without a buffer view, whose components are all zero. @@ -161,16 +202,19 @@ impl Doc<'_> { }; let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; let Some(bv) = idx(acc, "bufferView") else { - if total > MAX_ZERO_COMPONENTS { - return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})"))); - } + let left = self.zero_left.get(); + let Some(rest) = left.checked_sub(total) else { + return Err(perr(format!("accessors without a bufferView have more than {MAX_ZERO_COMPONENTS} components ({total} requested, {left} left)"))); + }; + self.zero_left.set(rest); + self.charge(total)?; return Ok((count, total, None)); }; let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; let past = || perr("accessor reads past its buffer"); let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; - let elem = csize * n; + let elem = csize.checked_mul(n).ok_or_else(past)?; let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); if stride < elem { return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); @@ -181,6 +225,7 @@ impl Doc<'_> { return Err(past()); } } + self.charge(total)?; Ok((count, total, Some(Span { buf, base, stride }))) } @@ -214,8 +259,7 @@ impl Doc<'_> { // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out[i * n + c] = match ct { 5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]), 5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32, @@ -264,8 +308,7 @@ impl Doc<'_> { let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out.push(match csize { 1 => s[0] as u32, 2 => u16::from_le_bytes([s[0], s[1]]) as u32, @@ -279,6 +322,11 @@ impl Doc<'_> { /// Parse a `.gltf` (JSON) or `.glb` file. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { + parse_with_budget(bytes, resolve, MAX_READ_COMPONENTS) +} + +/// [`parse`] with a read budget of `read_budget` accessor components for the whole file. +pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, read_budget: usize) -> Result { let (json, bin) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) }; let j: Value = serde_json::from_slice(json).map_err(|e| perr(format!("glTF JSON: {e}")))?; let ver = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or(""); @@ -300,7 +348,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< }; buffers.push(data); } - let d = Doc { j: &j, buffers }; + let d = Doc { j: &j, buffers, zero_left: Cell::new(MAX_ZERO_COMPONENTS), read_left: Cell::new(read_budget), read_cap: read_budget }; // Images → textures (decoded once per image; samplers set the wrap modes). let mut images: Vec>> = vec![]; @@ -567,7 +615,11 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result = match idx(p, "indices") { Some(a) => d.ints(a)?, - None => (0..n as u32).collect(), + None => { + // Generated indices are allocated like a read, once per referencing primitive. + d.charge(n)?; + (0..n as u32).collect() + } }; if raw.iter().any(|&i| i as usize >= n) { return Err(perr("index out of range")); @@ -587,11 +639,19 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result indices.extend(raw.as_chunks::<3>().0.iter().flatten()), } + // The triangle list is kept besides the raw indices (a strip or fan of k indices becomes + // 3(k-2)), so it is charged like a read. + d.charge(indices.len())?; if normals.len() != n { normals.clear(); } let material = idx(p, "material").filter(|&m| m < materials.len()); let skinned = joints.len() == n && weights.len() == n; + if normals.is_empty() { + // Flat normals unshare every vertex: position and normal, plus UVs and joints/weights. + let per = 6 + if uvs.len() == n { 2 } else { 0 } + if skinned { 8 } else { 0 }; + d.charge_each(indices.len(), per)?; + } let mut prim = Primitive { positions, normals, @@ -608,7 +668,20 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result Option<&str> { + let file = name.rsplit(['/', '\\']).next()?; + (!file.is_empty() && file.len() < name.len()).then_some(file) +} + +/// A material reference resolved against the model's folder: the name as written (a name +/// containing spaces is one name), then its file name (an absolute path from another machine, +/// #1274), then the final token (the options convention, `-bm 1 file.png`). +fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> Option> { + let mut tried: Vec<&str> = vec![]; + for candidate in [name, sibling_name(name).unwrap_or(name), name.split_whitespace().last().unwrap_or(name)] { + if tried.contains(&candidate) { + continue; + } + tried.push(candidate); + if let Some(b) = resolve(candidate) { + return Some(b); + } + } + None +} + /// Parse MTL text into named materials (textures appended to `textures`). pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { let mut out: Vec = vec![]; let mut loaded: HashMap> = HashMap::new(); let mut tex = |name: &str, textures: &mut Vec>| -> Option { - // Options (`-bm 1 file.png`): the file name is the last token. - let file = name.split_whitespace().last()?.to_string(); - let t = *loaded.entry(file.clone()).or_insert_with(|| { - let b = resolve(&file)?; - let t = Texture::decode(&b).ok()?; - textures.push(Arc::new(t)); - Some(textures.len() - 1) + let key = name.trim().to_string(); + let t = *loaded.entry(key.clone()).or_insert_with(|| { + let Some(b) = resolve_reference(resolve, &key) else { + log::warn!("model: texture `{key}` was not found next to the model"); + return None; + }; + match Texture::decode(&b) { + Ok(t) => { + textures.push(Arc::new(t)); + Some(textures.len() - 1) + } + Err(e) => { + log::warn!("model: cannot decode texture `{key}`: {e}"); + None + } + } }); t.map(|texture| TexRef { texture, uv_set: 0 }) }; @@ -147,13 +181,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); - for file in std::iter::once(name).chain(name.split_whitespace()) { + let mut found = false; + for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { if let Some(b) = resolve(file) { let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); materials.append(&mut ms); + found = true; break; } } + if !found && !name.is_empty() { + log::warn!("model: material library `{name}` was not found next to the model"); + } } "usemtl" => { let name = line["usemtl".len()..].trim(); diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 7c98c1ae7..5ee6a6950 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -119,6 +119,92 @@ fn obj_without_normals_gets_them() { assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err()); } +#[test] +fn obj_textures_from_another_machine_find_the_file_next_to_the_model() { + // No committed media: the material file and its textures are written by the test itself. + let d = std::env::temp_dir().join(format!("ec-obj-sibling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let png = { + let img = image::RgbaImage::from_fn(2, 2, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + std::fs::write(d.join("checker.png"), &png).unwrap(); + std::fs::write(d.join("Diffuse Map.png"), &png).unwrap(); + let mtl = "newmtl Mat\nKd 0.640000 0.640000 0.640000\nmap_Kd C:\\Users\\somebody\\Desktop\\textures\\checker.png\n\ + newmtl Spaced\nKd 1.000000 1.000000 1.000000\nmap_Kd Diffuse Map.png\n\ + newmtl Missing\nKd 0.500000 0.500000 0.500000\nmap_Kd nowhere.png\n"; + let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\ + usemtl Mat\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n"; + // Like the media layer's resolver: a relative name joins the model's folder; an absolute + // path is only looked up where it points (here: nowhere else). + let resolve = |uri: &str| -> Option> { + let p = std::path::Path::new(uri); + if p.is_absolute() { + return None; + } + std::fs::read(d.join(p)).ok() + }; + let resolver = |uri: &str| -> Option> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } }; + let m = obj::parse(obj.as_bytes(), &resolver).unwrap(); + assert_eq!(m.materials.len(), 3); + let mat = &m.materials[0]; + let t = mat.base_color_tex.expect("the absolute map_Kd path finds checker.png next to the .obj"); + let tex = &m.textures[t.texture]; + assert_eq!((tex.width, tex.height), (2, 2)); + assert_eq!(tex.data[0], [1.0, 0.0, 0.0, 1.0]); + assert!((mat.base_color[0] - obj::srgb_to_linear(0.64)).abs() < 1e-6, "Kd still tints the texture"); + assert!(m.materials[1].base_color_tex.is_some(), "a texture name containing spaces is one name"); + assert!(m.materials[2].base_color_tex.is_none(), "a genuinely missing texture keeps the Kd colour"); + assert!((m.materials[2].base_color[0] - obj::srgb_to_linear(0.5)).abs() < 1e-6); + // The same fallback applies to the material library itself. + let here = obj.replace("mtllib quad.mtl", "mtllib C:\\build\\quad.mtl"); + assert_eq!(obj::parse(here.as_bytes(), &resolver).unwrap().materials.len(), 3); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn obj_texture_prefers_the_complete_name_over_its_final_token() { + // Two textures sit beside the model: `Map.png` (the final whitespace token of the reference) + // and `Diffuse Map.png` (the complete name). The complete name must win, its basename next, + // the final token last (the `-bm 1 file.png` options convention). + let d = std::env::temp_dir().join(format!("ec-obj-full-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let png = |w: u32| { + let img = image::RgbaImage::from_fn(w, 2, |_, _| image::Rgba([255, 0, 0, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + std::fs::write(d.join("Map.png"), png(2)).unwrap(); + std::fs::write(d.join("Diffuse Map.png"), png(4)).unwrap(); + let mtl = "newmtl Spaced\nmap_Kd Diffuse Map.png\n\ + newmtl Absolute\nmap_Kd C:\\tex\\Diffuse Map.png\n\ + newmtl Options\nmap_Kd -bm 1 Map.png\n"; + let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\ + usemtl Spaced\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n"; + // Like the media layer's resolver: a relative name joins the model's folder; an absolute + // path is only looked up where it points (here: nowhere else). + let resolve = |uri: &str| -> Option> { + let p = std::path::Path::new(uri); + if p.is_absolute() { + return None; + } + std::fs::read(d.join(p)).ok() + }; + let resolver = |uri: &str| -> Option> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } }; + let m = obj::parse(obj.as_bytes(), &resolver).unwrap(); + assert_eq!(m.materials.len(), 3); + let size = |mat: &Material| mat.base_color_tex.map(|t| (m.textures[t.texture].width, m.textures[t.texture].height)); + assert_eq!(size(&m.materials[0]), Some((4, 2)), "the complete `Diffuse Map.png` wins over its final token `Map.png`"); + assert_eq!(size(&m.materials[1]), Some((4, 2)), "an absolute path's spaced basename beats its final token"); + assert_eq!(size(&m.materials[2]), Some((2, 2)), "the `-bm 1 file.png` options convention still finds the final token"); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn channel_interpolation_modes() { let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; @@ -564,3 +650,84 @@ fn gltf_strided_and_buffer_less_accessors_still_import() { let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); } + +#[test] +fn gltf_issue_2139_huge_mat4_count_is_an_error() { + // The audit's trigger (#2139): a MAT4 accessor with a count of 10^11, with and without a + // buffer view, must fail cleanly instead of sizing an allocation from the count. + let view = r#"{"buffer": 0, "byteLength": 64}"#; + for acc in [ + r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + r#"{"componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + ] { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 1}"#, view, 64) + .replace(r#""count": 1}]"#, &format!(r#""count": 1}}, {acc}]"#)) + .replace(r#""nodes": [{"mesh": 0}]"#, r#""nodes": [{"mesh": 0, "skin": 0}], "skins": [{"joints": [0], "inverseBindMatrices": 1}]"#); + assert!(doc.contains("inverseBindMatrices"), "{doc}"); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{acc}"); + } +} + +#[test] +fn gltf_buffer_less_accessors_share_one_zero_budget() { + // Each buffer-less accessor is capped, but nothing backs any of them, so referencing a capped + // one from many primitives must not multiply the allocation (#2139). + let acc = r#"{"componentType": 5126, "type": "VEC3", "count": 5000000}"#; + let prims = [r#"{"attributes": {"POSITION": 0}}"#; 4].join(", "); + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")); + assert_eq!(doc.matches(r#""POSITION": 0"#).count(), 4); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("without a bufferView"), "{e}"); + // One such accessor read once still imports. + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_ok()); +} + +/// `refs` primitives sharing one buffer-backed `verts`-vertex POSITION accessor, without indices. +fn gltf_shared_positions(verts: usize, refs: usize) -> String { + let acc = format!(r#"{{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": {verts}}}"#); + let prims = vec![r#"{"attributes": {"POSITION": 0}}"#; refs].join(", "); + gltf_doc(&acc, r#"{"buffer": 0}"#, verts * 12).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")) +} + +#[test] +fn gltf_buffer_backed_reads_share_one_read_budget() { + // A buffer-backed accessor is checked against its buffer, but each primitive that references + // it reads and keeps its own copy, so a small file listing many references must not ask for + // memory set by the reference count (#2139 review). Everything a primitive keeps is charged + // to one per-file budget: 1,000 vertices without indices or normals cost 3,000 position + // components, 1,000 generated indices, a 999-index triangle list and 999 flat-shaded + // vertices of 6 components, 10,993 in all. + let one = gltf_shared_positions(1000, 1); + let m = crate::gltf::parse_with_budget(one.as_bytes(), &|_| None, 20_000).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 1); + let three = gltf_shared_positions(1000, 3); + assert_eq!(three.matches(r#""POSITION": 0"#).count(), 3); + let e = crate::gltf::parse_with_budget(three.as_bytes(), &|_| None, 20_000).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("more than 20000 components"), "{e}"); + // The real cap leaves room for legitimate sharing: many primitives over one vertex buffer. + let m = crate::gltf::parse(gltf_shared_positions(1000, 50).as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 50); +} + +#[test] +fn gltf_missing_indices_are_charged_to_the_read_budget() { + // Without `indices` every vertex gets a generated index, allocated like a read: after the + // 3,000 position components, 999 left is one short for the 1,000 indices. + let doc = gltf_shared_positions(1000, 1); + let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 3999).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("(1000 requested, 999 left)"), "{e}"); +} + +#[test] +fn gltf_strip_expansion_is_charged_to_the_read_budget() { + // A strip of k indices becomes 3(k-2) triangle indices and as many flat-shaded vertices, so + // the output is charged, not only the 1,009 components read. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0}"#, 1036) + .replace(r#""count": 3}]"#, r#""count": 3}, {"bufferView": 0, "componentType": 5121, "type": "SCALAR", "count": 1000}]"#) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1, "mode": 5"#); + let m = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 30_000).unwrap(); + assert_eq!(m.meshes[0].primitives[0].indices.len(), 2994); + let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("more than 5000 components"), "{e}"); +} diff --git a/crates/opusenc/src/bands.rs b/crates/opusenc/src/bands.rs index cf3c590e8..f90a554c4 100644 --- a/crates/opusenc/src/bands.rs +++ b/crates/opusenc/src/bands.rs @@ -97,12 +97,24 @@ pub fn exp_rotation_inv(x: &mut [f32], len: usize, k: usize, spread: usize) { } } +/// Relative slack for the pulse-candidate filter in [`pvq_search`]: far above the f32 rounding of +/// the filter score (a few 1e-7), far below the spread of real scores. +const PVQ_SCORE_SLACK: f32 = 1e-5; + /// Greedy PVQ search: the integer vector `y` with `sum |y| = k` closest in direction to `x`. pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { let n = x.len(); y[..n].fill(0); - let abs: Vec = x.iter().map(|v| v.abs()).collect(); - let sum: f32 = abs.iter().sum(); + // `abs`, `dy` (2 y + 1, infinite in the padding) and the per-pass scores `score`, each padded to + // whole 8-lane chunks. + let np = n.next_multiple_of(8); + let mut buf = vec![0f32; 3 * np]; + let (abs, rest) = buf.split_at_mut(np); + let (dy, score) = rest.split_at_mut(np); + for (a, v) in abs.iter_mut().zip(x) { + *a = v.abs(); + } + let sum: f32 = abs[..n].iter().sum(); if !sum.is_finite() || sum <= 1e-15 { y[0] = k as i32; return; @@ -114,30 +126,69 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { // Pre-project onto the pyramid (rounding down) and place the rest greedily. let r = (k as f32 - 1.0) / sum; for j in 0..n { - let p = (abs[j] * r).floor() as i32; + // Non-negative and at most k, so the truncating cast is the floor. + let p = (abs[j] * r) as i32; y[j] = p; placed += p as usize; xy += abs[j] * p as f32; yy += (p * p) as f32; } } + for j in 0..np { + dy[j] = if j < n { 2.0 * y[j] as f32 + 1.0 } else { f32::INFINITY }; + } while placed < k { + // Vectorisable scores (xy + |x_j|)^2 / (yy + 2 y_j + 1) and their maximum. + for ((sc, &a), &d) in score.iter_mut().zip(abs.iter()).zip(dy.iter()) { + let t = xy + a; + *sc = t * t / (yy + d); + } + let mut lane_max = [0f32; 8]; + for c in score.as_chunks::<8>().0 { + for i in 0..8 { + lane_max[i] = if c[i] > lane_max[i] { c[i] } else { lane_max[i] }; + } + } + let top = lane_max.iter().fold(0f32, |m, &v| if v > m { v } else { m }); + // The exact cross-multiplied test below, in index order with the first maximum winning, only + // matters for entries within rounding distance of the top score: every other entry is beaten + // by the top one whenever it is reached and never beats it afterwards. Running the test on + // those alone therefore picks the same pulse as running it on all of them. + let filter = top > 1e-30 && top.is_finite(); + let lo = top * (1.0 - PVQ_SCORE_SLACK); + // Usually exactly one entry qualifies; its index falls out of a lane-wise count. + let mut count = [0u32; 8]; + let mut index = [0u32; 8]; + for (ci, c) in score.as_chunks::<8>().0.iter().enumerate() { + for i in 0..8 { + let hit = u32::from(c[i] >= lo); + count[i] += hit; + index[i] += hit * (ci * 8 + i) as u32; + } + } let mut best = 0usize; - let mut best_num = -1f32; - let mut best_den = 1f32; - for j in 0..n { - let num = xy + abs[j]; - let num = num * num; - let den = yy + 2.0 * y[j] as f32 + 1.0; - if num * best_den > best_num * den { - best_num = num; - best_den = den; - best = j; + if filter && count.iter().sum::() == 1 { + best = index.iter().sum::() as usize; + } else { + let mut best_num = -1f32; + let mut best_den = 1f32; + for j in 0..n { + if !filter || score[j] >= lo { + let num = xy + abs[j]; + let num = num * num; + let den = yy + 2.0 * y[j] as f32 + 1.0; + if num * best_den > best_num * den { + best_num = num; + best_den = den; + best = j; + } + } } } xy += abs[best]; yy += 2.0 * y[best] as f32 + 1.0; y[best] += 1; + dy[best] = 2.0 * y[best] as f32 + 1.0; placed += 1; } for j in 0..n { @@ -147,25 +198,45 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { } } -/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps -/// back to `y`. -pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec) { - let n = y.len(); - let w = k + 1; - v.clear(); - v.resize((n + 1) * w, 0); +/// Largest band width and pulse count the encoder codes (176 coefficients, `get_pulses(40)` = 128). +const PVQ_MAX_N: usize = 176; +const PVQ_MAX_K: usize = 128; + +/// Row-major `V(n, k)` for `n <= max_n`, `k <= max_k` with row stride `max_k + 1`, saturated at +/// `u32::MAX` (a codebook that large is never coded, see `rate::fits_in32`). +fn build_pvq_table(max_n: usize, max_k: usize) -> Vec { + let w = max_k + 1; + let mut v = vec![0u32; (max_n + 1) * w]; v[0] = 1; - for nn in 1..=n { + for nn in 1..=max_n { v[nn * w] = 1; - for kk in 1..=k { - let a = v[(nn - 1) * w + kk]; - let b = v[nn * w + kk - 1]; - let c = v[(nn - 1) * w + kk - 1]; - v[nn * w + kk] = a.saturating_add(b).saturating_add(c); + for kk in 1..=max_k { + let sum = v[(nn - 1) * w + kk] as u64 + v[nn * w + kk - 1] as u64 + v[(nn - 1) * w + kk - 1] as u64; + v[nn * w + kk] = sum.min(u32::MAX as u64) as u32; } } - let total = v[n * w + k]; - let vv = |nn: usize, kx: usize| v[nn * w + kx]; + v +} + +fn pvq_table() -> &'static [u32] { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| build_pvq_table(PVQ_MAX_N, PVQ_MAX_K)) +} + +/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps +/// back to `y`. +pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder) { + let n = y.len(); + // Shapes outside the coded range (never produced by the band layout) get a local table. + let local; + let (v, w) = if n <= PVQ_MAX_N && k <= PVQ_MAX_K { + (pvq_table(), PVQ_MAX_K + 1) + } else { + local = build_pvq_table(n, k); + (&local[..], k + 1) + }; + let row = |nn: usize| &v[nn * w..(nn + 1) * w]; + let total = row(n)[k] as u64; let mut index = 0u64; let mut kk = k; for j in 0..n { @@ -174,10 +245,11 @@ pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec { pub i: usize, pub spread: usize, pub remaining_bits: i32, - pvq_v: Vec, iy: Vec, tmp: Vec, } @@ -288,7 +359,7 @@ impl BandEnc<'_, '_> { iy.clear(); iy.resize(n, 0); pvq_search(&tmp, k, &mut iy); - encode_pulses(&iy, k, self.enc, &mut self.pvq_v); + encode_pulses(&iy, k, self.enc); self.iy = iy; self.tmp = tmp; } @@ -310,7 +381,7 @@ pub fn quant_all_bands(m: &Mode, frame: CeltFrame, x: &[f32], n_total: usize, al let BandCoding { spread, total_bits } = coding; let mm = 1usize << lm; debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo"); - let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() }; + let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, iy: Vec::new(), tmp: Vec::new() }; for i in start..end { ctx.i = i; let xo = mm * EBANDS[i] as usize; @@ -460,12 +531,11 @@ mod tests { #[test] fn pvq_index_roundtrip_exhaustive() { - let mut v = Vec::new(); for n in 1..6usize { for k in 1..6usize { for y in all_codewords(n, k) { let mut e = RangeEncoder::new(); - encode_pulses(&y, k, &mut e, &mut v); + encode_pulses(&y, k, &mut e); let buf = e.done(16); let mut d = RangeDecoder::new(&buf); assert_eq!(decode_pulses(n, k, &mut d), y, "n={n} k={k}"); @@ -474,6 +544,165 @@ mod tests { } } + /// The per-call table encoder this module used before the shared table. + fn encode_pulses_reference(y: &[i32], k: usize, enc: &mut RangeEncoder) { + let n = y.len(); + let w = k + 1; + let mut v = vec![0u64; (n + 1) * w]; + v[0] = 1; + for nn in 1..=n { + v[nn * w] = 1; + for kk in 1..=k { + let a = v[(nn - 1) * w + kk]; + let b = v[nn * w + kk - 1]; + let c = v[(nn - 1) * w + kk - 1]; + v[nn * w + kk] = a.saturating_add(b).saturating_add(c); + } + } + let total = v[n * w + k]; + let vv = |nn: usize, kx: usize| v[nn * w + kx]; + let mut index = 0u64; + let mut kk = k; + for j in 0..n { + if kk == 0 { + break; + } + let rem = n - j; + let t = y[j].unsigned_abs() as usize; + let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum(); + if y[j] < 0 { + base += (vv(rem - 1, kk) + vv(rem, kk)) / 2; + } + index += base; + kk -= t; + } + if total >= 2 { + enc.uint(index as u32, total as u32); + } + } + + #[test] + fn pvq_table_encode_matches_per_call_table() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move |m: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % m + }; + let mut checked = 0; + for n in (1..=PVQ_MAX_N).step_by(5).chain([PVQ_MAX_N]) { + for k in (1..=PVQ_MAX_K).step_by(3).chain([PVQ_MAX_K]) { + if crate::rate::pvq_v(n, k) > u32::MAX as u128 { + continue; + } + for _ in 0..4 { + let mut y = vec![0i32; n]; + for _ in 0..k { + let at = next(n as u64) as usize; + y[at] += if next(2) == 0 { 1 } else { -1 }; + } + // Opposite pulses cancel; top up so that sum |y| = k again. + let mut have: usize = y.iter().map(|v| v.unsigned_abs() as usize).sum(); + while have < k { + let at = next(n as u64) as usize; + y[at] += if y[at] < 0 { -1 } else { 1 }; + have += 1; + } + let (mut a, mut b) = (RangeEncoder::new(), RangeEncoder::new()); + encode_pulses(&y, k, &mut a); + encode_pulses_reference(&y, k, &mut b); + assert_eq!(a.done(16), b.done(16), "n={n} k={k}"); + checked += 1; + } + } + } + assert!(checked > 100, "{checked}"); + } + + /// The scalar greedy search the vectorised one replaced. + fn pvq_search_ref(x: &[f32], k: usize, y: &mut [i32]) { + let n = x.len(); + y[..n].fill(0); + let abs: Vec = x.iter().map(|v| v.abs()).collect(); + let sum: f32 = abs.iter().sum(); + if !sum.is_finite() || sum <= 1e-15 { + y[0] = k as i32; + return; + } + let mut placed = 0usize; + let mut xy = 0f32; + let mut yy = 0f32; + if k > n / 2 { + let r = (k as f32 - 1.0) / sum; + for j in 0..n { + let p = (abs[j] * r).floor() as i32; + y[j] = p; + placed += p as usize; + xy += abs[j] * p as f32; + yy += (p * p) as f32; + } + } + while placed < k { + let mut best = 0usize; + let mut best_num = -1f32; + let mut best_den = 1f32; + for j in 0..n { + let num = xy + abs[j]; + let num = num * num; + let den = yy + 2.0 * y[j] as f32 + 1.0; + if num * best_den > best_num * den { + best_num = num; + best_den = den; + best = j; + } + } + xy += abs[best]; + yy += 2.0 * y[best] as f32 + 1.0; + y[best] += 1; + placed += 1; + } + for j in 0..n { + if x[j] < 0.0 { + y[j] = -y[j]; + } + } + } + + #[test] + fn pvq_search_matches_scalar_reference() { + let mut state = 0x9e3779b97f4a7c15u64; + let mut rnd = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0 + }; + let mut a = vec![0i32; 200]; + let mut b = vec![0i32; 200]; + let mut cases = 0; + for round in 0..6000 { + let n = 1 + (round * 37 + (rnd().abs() * 176.0) as usize) % 176; + let k = 1 + (rnd().abs() * 127.0) as usize; + let scale = [1.0f32, 1e-9, 1e6, 3e-3][round % 4]; + let x: Vec = (0..n) + .map(|_| match round % 6 { + 0 => rnd(), + 1 => if rnd() > 0.8 { rnd() } else { 0.0 }, + 2 => (rnd() * 4.0).round() / 4.0, + 3 => if rnd() > 0.0 { 1.0 } else { -1.0 }, + 4 => rnd().powi(5), + _ => if rnd() > 0.95 { 3.0 * rnd() } else { 1e-4 * rnd() }, + } * scale) + .collect(); + pvq_search(&x, k, &mut a); + pvq_search_ref(&x, k, &mut b); + assert_eq!(a[..n], b[..n], "n={n} k={k} round={round}"); + cases += 1; + } + assert_eq!(cases, 6000); + } + #[test] fn pvq_search_finds_good_direction() { let x: Vec = (0..32).map(|i| ((i as f32) * 0.7).sin()).collect(); diff --git a/crates/opusenc/src/mdct.rs b/crates/opusenc/src/mdct.rs index 814de0375..131c4a7a1 100644 --- a/crates/opusenc/src/mdct.rs +++ b/crates/opusenc/src/mdct.rs @@ -24,6 +24,10 @@ impl Cpx { fn add(self, o: Cpx) -> Cpx { Cpx { re: self.re + o.re, im: self.im + o.im } } + #[inline] + fn sub(self, o: Cpx) -> Cpx { + Cpx { re: self.re - o.re, im: self.im - o.im } + } } /// Mixed-radix (2, 3, 4, 5) forward complex FFT, unscaled (decimation in time, recursive). @@ -71,18 +75,64 @@ impl Fft { self.rec(&mut out[q * m..(q + 1) * m], &inp[q * in_stride..], in_stride * p, m, fi + 1, tw_stride * p); } } - let big = self.n; - let mut t = [Cpx::default(); 5]; - for k in 0..m { - for (q, tq) in t.iter_mut().enumerate().take(p) { - *tq = out[q * m + k].mul(self.twiddle[(q * k * tw_stride) % big]); + // The twiddle index q * k * tw_stride is always below n, so no modulo is needed. + let tw = &self.twiddle; + match p { + 2 => { + for k in 0..m { + let a0 = out[k]; + let a1 = out[m + k].mul(tw[k * tw_stride]); + out[k] = a0.add(a1); + out[m + k] = a0.sub(a1); + } } - for q2 in 0..p { - let mut acc = Cpx::default(); - for (q, tq) in t.iter().enumerate().take(p) { - acc = acc.add(tq.mul(self.twiddle[((q * q2) % p) * (big / p)])); + 4 => { + for k in 0..m { + let t0 = out[k]; + let t1 = out[m + k].mul(tw[k * tw_stride]); + let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]); + let t3 = out[3 * m + k].mul(tw[3 * k * tw_stride]); + let (s0, d0) = (t0.add(t2), t0.sub(t2)); + let (s1, d1) = (t1.add(t3), t1.sub(t3)); + out[k] = s0.add(s1); + out[2 * m + k] = s0.sub(s1); + // The odd outputs combine d0 with -j * d1 = (d1.im, -d1.re) and its negative. + out[m + k] = Cpx { re: d0.re + d1.im, im: d0.im - d1.re }; + out[3 * m + k] = Cpx { re: d0.re - d1.im, im: d0.im + d1.re }; + } + } + 3 => { + // Imaginary part of exp(-2 pi i / 3) = -sqrt(3) / 2. + let h = tw[self.n / 3].im; + for k in 0..m { + let t0 = out[k]; + let t1 = out[m + k].mul(tw[k * tw_stride]); + let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]); + let (s, d) = (t1.add(t2), t1.sub(t2)); + let a = Cpx { re: t0.re - 0.5 * s.re, im: t0.im - 0.5 * s.im }; + out[k] = t0.add(s); + // j * h * d = (-h * d.im, h * d.re) + out[m + k] = Cpx { re: a.re - h * d.im, im: a.im + h * d.re }; + out[2 * m + k] = Cpx { re: a.re + h * d.im, im: a.im - h * d.re }; + } + } + _ => { + // Generic DFT butterfly (radix 5, once per transform). + let roots: [Cpx; 5] = std::array::from_fn(|q| if q < p { tw[q * (self.n / p)] } else { Cpx::default() }); + let mut t = [Cpx::default(); 5]; + for k in 0..m { + t[0] = out[k]; + for q in 1..p { + t[q] = out[q * m + k].mul(tw[q * k * tw_stride]); + } + for q2 in 0..p { + let mut acc = t[0]; + for q in 1..p { + acc = acc.add(t[q].mul(roots[(q * q2) % p])); + } + out[q2 * m + k] = acc; + } } - out[q2 * m + k] = acc; } } } @@ -198,6 +248,26 @@ mod tests { .collect() } + #[test] + fn fft_matches_dft() { + // Every radix combination, including the 480-point size of the 20 ms frame. + for &n in &[2usize, 3, 4, 5, 6, 8, 12, 15, 16, 20, 24, 30, 48, 60, 120, 240, 480] { + let fft = Fft::new(n); + let x: Vec = (0..n).map(|i| Cpx { re: ((i * 37 % 101) as f32 - 50.0) / 7.0, im: ((i * 53 % 89) as f32 - 44.0) / 9.0 }).collect(); + let mut y = x.clone(); + fft.process(&mut y, &mut Vec::new()); + for k in 0..n { + let (mut re, mut im) = (0f64, 0f64); + for (j, v) in x.iter().enumerate() { + let ph = -2.0 * std::f64::consts::PI * ((j * k) % n) as f64 / n as f64; + re += v.re as f64 * ph.cos() - v.im as f64 * ph.sin(); + im += v.re as f64 * ph.sin() + v.im as f64 * ph.cos(); + } + assert!((y[k].re as f64 - re).abs() < 2e-3 && (y[k].im as f64 - im).abs() < 2e-3, "n={n} k={k}: ({}, {}) vs ({re}, {im})", y[k].re, y[k].im); + } + } + } + #[test] fn matches_direct_formula() { for &n in &[240usize, 960] { diff --git a/crates/opusenc/src/resample.rs b/crates/opusenc/src/resample.rs index 9045cb86d..1acc9a4e7 100644 --- a/crates/opusenc/src/resample.rs +++ b/crates/opusenc/src/resample.rs @@ -45,6 +45,25 @@ fn design(fc: f64, beta: f64, delay: usize) -> Vec { h.iter().map(|&v| v as f32).collect() } +/// Dot product with eight independent partial sums, which the compiler can vectorise (a single +/// running sum is bound by the latency of the float addition). The sums are combined pairwise. +#[inline] +fn dot8(a: &[f32], b: &[f32]) -> f32 { + let mut s = [0f32; 8]; + let (ca, ra) = a.as_chunks::<8>(); + let (cb, rb) = b.as_chunks::<8>(); + for (x, y) in ca.iter().zip(cb) { + for l in 0..8 { + s[l] += x[l] * y[l]; + } + } + let mut acc = ((s[0] + s[4]) + (s[1] + s[5])) + ((s[2] + s[6]) + (s[3] + s[7])); + for (x, y) in ra.iter().zip(rb) { + acc += x * y; + } + acc +} + /// Decimates one channel of 48 kHz audio by 3, 4 or 6. #[derive(Clone)] pub struct Downsampler { @@ -77,7 +96,7 @@ impl Downsampler { let t = taps - 1 + self.factor * j; let win = &self.buf[t + 1 - taps..=t]; // h is symmetric, so the window can be taken in forward order. - out.push(win.iter().zip(&self.h).map(|(a, b)| a * b).sum()); + out.push(dot8(win, &self.h)); } let n = self.buf.len(); self.buf.copy_within(n - (taps - 1).., 0); @@ -97,6 +116,16 @@ mod tests { (x.iter().map(|&v| (v as f64).powi(2)).sum::() / x.len() as f64).sqrt() } + #[test] + fn dot8_matches_sequential_sum() { + for len in [0usize, 1, 7, 8, 9, 63, 167, 251] { + let a: Vec = (0..len).map(|i| ((i * 37 % 101) as f32 - 50.0) / 50.0).collect(); + let b: Vec = (0..len).map(|i| ((i * 53 % 89) as f32 - 44.0) / 44.0).collect(); + let exact: f64 = a.iter().zip(&b).map(|(&x, &y)| x as f64 * y as f64).sum(); + assert!((dot8(&a, &b) as f64 - exact).abs() < 1e-4, "len {len}"); + } + } + #[test] fn passes_band_and_rejects_aliases() { for &(fs, pass, stop) in &[(16_000u32, 6_000.0, 8_600.0), (12_000, 4_500.0, 6_500.0), (8_000, 3_000.0, 4_500.0)] { diff --git a/crates/opusenc/src/silk/analysis.rs b/crates/opusenc/src/silk/analysis.rs index 439331076..d80052046 100644 --- a/crates/opusenc/src/silk/analysis.rs +++ b/crates/opusenc/src/silk/analysis.rs @@ -69,28 +69,55 @@ pub fn levinson(r: &[f64], order: usize) -> Vec { } /// Evaluates the symmetric polynomial `p` (length `d + 1`, `d` even) as the cosine series -/// `e^{jωd/2} P(e^{jω})`. -fn cos_series(p: &[f64], w: f64) -> f64 { +/// `e^{jωd/2} P(e^{jω})`, with `cos_m(m)` giving `cos(m ω)`. +#[inline] +fn cos_series_with(p: &[f64], cos_m: impl Fn(usize) -> f64) -> f64 { let h = (p.len() - 1) / 2; let mut s = p[h]; for k in 0..h { - s += 2.0 * p[k] * ((h - k) as f64 * w).cos(); + s += 2.0 * p[k] * cos_m(h - k); } s } +fn cos_series(p: &[f64], w: f64) -> f64 { + cos_series_with(p, |m| (m as f64 * w).cos()) +} + +/// Number of grid intervals the root search scans over `[0, π]`. +const GRID: usize = 1024; +/// Largest multiple of ω in the series (half the LPC order, at most 16 / 2). +const MAX_H: usize = 8; +/// Bisection steps per root: the bracket shrinks to 3e-9 rad, far below one Q15 NLSF step (9.6e-5 rad). +const BISECT_STEPS: usize = 20; + +/// `cos(m · π g / GRID)` for every grid point `g` and `m` in `0..=MAX_H`, computed with the same +/// expression as [`cos_series`], so the table values are bit-identical to calling `cos` there. +fn grid_cos() -> &'static [[f64; MAX_H + 1]] { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| { + (0..=GRID) + .map(|g| { + let w = std::f64::consts::PI * g as f64 / GRID as f64; + std::array::from_fn(|m| (m as f64 * w).cos()) + }) + .collect() + }) +} + fn roots(p: &[f64], out: &mut Vec) { - const GRID: usize = 1024; + let grid = grid_cos(); + let at_grid = |g: usize| cos_series_with(p, |m| grid[g][m]); let mut w0 = 0.0; - let mut f0 = cos_series(p, w0); + let mut f0 = at_grid(0); for g in 1..=GRID { let w1 = std::f64::consts::PI * g as f64 / GRID as f64; - let f1 = cos_series(p, w1); + let f1 = at_grid(g); if f0 == 0.0 { out.push(w0); } else if f0 * f1 < 0.0 { let (mut lo, mut hi, mut flo) = (w0, w1, f0); - for _ in 0..40 { + for _ in 0..BISECT_STEPS { let mid = 0.5 * (lo + hi); let fm = cos_series(p, mid); if fm * flo <= 0.0 { @@ -111,6 +138,9 @@ fn roots(p: &[f64], out: &mut Vec) { /// when the root search fails (unstable or degenerate filter). pub fn a2nlsf(a: &[f64]) -> Option> { let d = a.len(); + if d > 2 * MAX_H { + return None; + } let mut c = vec![0f64; d + 2]; c[0] = 1.0; for k in 0..d { @@ -199,9 +229,20 @@ pub fn pitch_search(x: &[f32], start: usize, len: usize, fs_khz: usize) -> Pitch let score = full[(lag - min_lag) as usize]; // Contour: per-subframe lag offsets around the chosen lag. let cb: &[[i32; 4]] = if fs_khz == 8 { &CB_NB_20 } else { &CB_WB_20 }; + // The contour search revisits the same (subframe, lag) pairs for neighbouring base lags and + // codebook entries, so each correlation is computed once. + let span = (18 * fs - min_lag + 1) as usize; + let memo = std::cell::RefCell::new(vec![f64::NAN; 4 * span]); let sub_score = |k: usize, l: i32| -> f64 { let l = l.clamp(min_lag, 18 * fs); - ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize) + let slot = k * span + (l - min_lag) as usize; + let cached = memo.borrow()[slot]; + if !cached.is_nan() { + return cached; + } + let v = ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize); + memo.borrow_mut()[slot] = v; + v }; let mut best = (f64::MIN, lag, 0usize); for l0 in (lag - 2).max(min_lag)..=(lag + 2).min(max_lag) { @@ -319,4 +360,59 @@ mod tests { } } } + + /// The root search as it was before the grid table: `cos` called for every evaluation. + fn roots_reference(p: &[f64], out: &mut Vec) { + let mut w0 = 0.0; + let mut f0 = cos_series(p, w0); + for g in 1..=GRID { + let w1 = std::f64::consts::PI * g as f64 / GRID as f64; + let f1 = cos_series(p, w1); + if f0 == 0.0 { + out.push(w0); + } else if f0 * f1 < 0.0 { + let (mut lo, mut hi, mut flo) = (w0, w1, f0); + for _ in 0..BISECT_STEPS { + let mid = 0.5 * (lo + hi); + let fm = cos_series(p, mid); + if fm * flo <= 0.0 { + hi = mid; + } else { + lo = mid; + flo = fm; + } + } + out.push(0.5 * (lo + hi)); + } + w0 = w1; + f0 = f1; + } + } + + #[test] + fn grid_table_roots_are_bit_identical_to_direct_cos() { + let mut seed = 7u64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + }; + let mut found = 0; + for len in [11usize, 17] { + for _ in 0..300 { + let mut p = vec![0f64; len]; + for k in 0..len / 2 + 1 { + p[k] = rnd(); + p[len - 1 - k] = p[k]; + } + let (mut a, mut b) = (Vec::new(), Vec::new()); + roots(&p, &mut a); + roots_reference(&p, &mut b); + assert_eq!(a.iter().map(|v| v.to_bits()).collect::>(), b.iter().map(|v| v.to_bits()).collect::>()); + found += a.len(); + } + } + assert!(found > 1000, "{found}"); + } } diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index c52b3d262..83e2102dc 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -130,13 +130,38 @@ pub fn from_kurbo(path: &BezPath) -> Vec { out } -/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). +/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it +/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with +/// round corners from the end of the top-right arc on the right edge (After Effects 2026, +/// 400 × 300 rectangles trimmed 0–25 %, case 930). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { - let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let max_rad = size[0].abs().min(size[1].abs()) / 2.0; + let (w, h) = (size[0].abs(), size[1].abs()); + let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0); + let max_rad = w.min(h) / 2.0; // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness. - let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) }; - if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } + let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } else { 0.0 }; + let mut p = BezPath::new(); + if rad <= 0.0 { + p.move_to((x1, y0)); + p.line_to((x1, y1)); + p.line_to((x0, y1)); + p.line_to((x0, y0)); + p.close_path(); + return p; + } + // Quarter circles as cubics (handle 0.5523 × radius). + let k = rad * 0.552_284_749_8; + p.move_to((x1, y0 + rad)); + p.line_to((x1, y1 - rad)); + p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1)); + p.line_to((x0 + rad, y1)); + p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad)); + p.line_to((x0, y0 + rad)); + p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0)); + p.line_to((x1 - rad, y0)); + p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad)); + p.close_path(); + p } pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { @@ -369,6 +394,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + /// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain: + /// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom. + /// Round corners 40: from the end of the top-right arc (200, −110) down, round the + /// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %. + #[test] + fn rectangles_start_where_after_effects_starts_them() { + let ends = |p: &BezPath| { + let mut first = None; + let mut last = None; + for el in p.elements() { + match *el { + PathEl::MoveTo(q) if first.is_none() => first = Some(q), + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q), + PathEl::ClosePath => {} + } + } + (first.unwrap_or_default(), last.unwrap_or_default()) + }; + let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0; + let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&plain[0]); + assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}"); + let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&round[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). + let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); + let (a, b) = ends(&swapped[0]); + assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}"); + } + #[test] fn rect_with_nan_size_does_not_panic() { let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index b78e3d8c1..95ed262d4 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -881,6 +881,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -910,7 +918,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 5a3c8650e..36069e637 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -275,15 +275,35 @@ impl Comp { } } +/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it is +/// part of the pixels even where plain markers are not. +fn is_timing_marker(m: &Marker) -> bool { + m.protected && m.duration > Tick::ZERO +} + +fn timing_markers(m: &[Marker]) -> impl Iterator + '_ { + m.iter().filter(|m| is_timing_marker(m)).map(|m| (m.time, m.duration)) +} + impl Comp { /// 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 { - self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + self.same_pixels_with(o, true) } - /// [`Self::same_pixels`] but for the layers. - fn same_settings(&self, o: &Comp) -> bool { + /// [`Self::same_pixels`], with the comp's and its layers' markers counting only when + /// `with_markers`: only expressions read them, so while none does, adding or moving a marker + /// (even during playback) leaves every rendered frame valid. Protected time markers with a + /// duration set the timing (Responsive Design), so those always count. + pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool { + self.same_settings(o, with_markers) + && self.layers.len() == o.layers.len() + && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers)) + } + + /// [`Self::same_pixels_with`] but for the layers. + fn same_settings(&self, o: &Comp, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here). let Comp { width, @@ -321,7 +341,7 @@ impl Comp { && *display_start == o.display_start && *background == o.background && *work_area == o.work_area - && *markers == o.markers + && (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) }) && *shutter_angle == o.shutter_angle && *shutter_phase == o.shutter_phase && *motion_blur_samples == o.motion_blur_samples @@ -356,7 +376,7 @@ impl Comp { }) } // Solo hides every other layer at every frame. - if !self.same_settings(o) || self.any_solo() != o.any_solo() { + if !self.same_settings(o, true) || self.any_solo() != o.any_solo() { return None; } let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::>(); @@ -393,34 +413,33 @@ impl Comp { self.layers.iter().any(|l| l.switches.solo && l.source.is_av()) } - /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected - /// time markers with a duration set the timing (Responsive Design), so those still count. - pub fn same_pixels_ignoring_markers(&self, o: &Comp) -> bool { - let protected = - |m: &[Marker]| -> Vec<(Tick, Tick)> { m.iter().filter(|m| m.protected && m.duration > Tick::ZERO).map(|m| (m.time, m.duration)).collect() }; - if protected(&self.markers) != protected(&o.markers) { - return false; - } - let mut c = self.clone(); - c.markers = o.markers.clone(); - for (a, b) in c.layers.iter_mut().zip(&o.layers) { - a.markers = b.markers.clone(); - a.markers_locked = b.markers_locked; - } - c.same_pixels(o) - } - /// 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.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + self.pixel_form_with(true) + } + + /// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not + /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]). + pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> { + if !self.hide_shy + && self.resolution.is_auto() + && self.layers.iter().all(|l| l.switches == l.switches.pixels()) + && (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty()))) + { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; c.resolution = Resolution::Auto; + if !with_markers { + c.markers.retain(is_timing_marker); + } for l in &mut c.layers { l.switches = l.switches.pixels(); + if !with_markers { + l.markers.clear(); + } } std::borrow::Cow::Owned(c) } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index ae5f113d5..336272d77 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,4 +1,4 @@ -use super::settings::yes; +use super::settings::{Marker, yes}; use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -254,6 +254,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -290,6 +294,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 3f53bc823..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -98,6 +98,21 @@ impl Default for Project { } impl Project { + /// Some layer has an expression, which can read markers (`marker.key(1)`, `numKeys`, + /// `nearestKey`...). Only then do the markers of comps and layers affect what is rendered, so + /// frame caches ignore them otherwise. + pub fn reads_markers(&self) -> bool { + // Called for every frame identity and disk-cache key: stops at the first expression and + // builds no paths (`PropGroup::walk` formats one per node). + fn any_expr(g: &crate::PropGroup) -> bool { + g.children.iter().any(|c| match c { + crate::Node::Prop(p) => p.expr.is_some(), + crate::Node::Group(sub) => any_expr(sub), + }) + } + self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props))) + } + /// Allocate an id (items, layers and property uids share one counter). pub fn alloc(&mut self) -> u64 { let id = self.next_id; @@ -252,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -274,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] 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})")))?; diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 25e2da7c4..21307583d 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -195,6 +195,12 @@ impl Switches { impl Layer { /// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches. pub fn same_pixels(&self, o: &Layer) -> bool { + self.same_pixels_with(o, true) + } + + /// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by + /// expressions alone, so a project without any can edit them freely). + pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here); the property tree last. let Layer { id, @@ -232,7 +238,7 @@ impl Layer { && *preserve_transparency == o.preserve_transparency && *track_matte == o.track_matte && *parent == o.parent - && *markers == o.markers + && (!with_markers || *markers == o.markers) && *markers_locked == o.markers_locked && *auto_orient == o.auto_orient && *environment == o.environment diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..be2468c37 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -205,7 +205,8 @@ pub enum MaskMotionBlur { Off, } -/// How a mask's feather ramps: Smooth (Gaussian) or Linear. +/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as +/// measured in After Effects) or Linear (one box pass: a straight ramp). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FeatherFalloff { #[default] diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..1a829630c 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,9 +442,18 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -509,6 +518,9 @@ pub enum OutputFormat { TiffSequence, /// OpenEXR sequence (32-bit float, linear light). ExrSequence, + /// Targa sequence: uncompressed 24-bit RGB or 32-bit RGB + Alpha (8 alpha bits), top-left + /// origin. + TargaSequence, /// Animated GIF. Gif, /// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio. @@ -528,7 +540,7 @@ pub enum OutputFormat { } impl OutputFormat { - pub const ALL: [OutputFormat; 13] = [ + pub const ALL: [OutputFormat; 14] = [ OutputFormat::H264, OutputFormat::Hevc, OutputFormat::Av1, @@ -539,6 +551,7 @@ impl OutputFormat { OutputFormat::JpegSequence, OutputFormat::TiffSequence, OutputFormat::ExrSequence, + OutputFormat::TargaSequence, OutputFormat::Gif, OutputFormat::Wav, OutputFormat::Aiff, @@ -552,6 +565,7 @@ impl OutputFormat { OutputFormat::JpegSequence => "JPEG Sequence", OutputFormat::TiffSequence => "TIFF Sequence", OutputFormat::ExrSequence => "OpenEXR Sequence", + OutputFormat::TargaSequence => "Targa Sequence", OutputFormat::Gif => "Animated GIF", OutputFormat::WebM => "WebM (VP9 + Opus)", OutputFormat::Wav => "WAV", @@ -569,6 +583,7 @@ impl OutputFormat { OutputFormat::JpegSequence => "jpg", OutputFormat::TiffSequence => "tif", OutputFormat::ExrSequence => "exr", + OutputFormat::TargaSequence => "tga", OutputFormat::Gif => "gif", OutputFormat::WebM => "webm", OutputFormat::Wav => "wav", @@ -577,7 +592,10 @@ impl OutputFormat { } } pub fn is_sequence(self) -> bool { - matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) + matches!( + self, + OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence | OutputFormat::TargaSequence + ) } pub fn is_movie(self) -> bool { matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1) @@ -603,7 +621,7 @@ impl OutputFormat { if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) } } /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`, - /// `tiff`/`tif`, `exr`, `gif`, or a label. + /// `tiff`/`tif`, `exr`, `tga`/`targa`, `gif`, or a label. pub fn from_name(s: &str) -> Option { let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], ""); Some(match n.as_str() { @@ -616,6 +634,7 @@ impl OutputFormat { "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence, "exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence, + "tga" | "targa" | "targasequence" => OutputFormat::TargaSequence, "gif" | "animatedgif" => OutputFormat::Gif, "webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM, "wav" | "wave" => OutputFormat::Wav, @@ -769,15 +788,18 @@ impl Crop { if self.use_roi && let Some([x, y, rw, rh]) = self.roi { - // The quad arrives unvalidated (control channel or a hand-edited project): - // clamp like the sibling edge fields (plus/minus 20 000) and subtract - // without overflow. - let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32; - let l = conv(x * scale); - let t = conv(y * scale); - let r = (w as i32).saturating_sub(conv((x + rw) * scale)); - let b = (h as i32).saturating_sub(conv((y + rh) * scale)); - return (t, l, b, r); + // The quad arrives unvalidated (control channel or a hand-edited project). As in + // After Effects, the region of interest lies inside the frame: each converted edge + // is clamped to [0, w] / [0, h] (inf clamps to a side, NaN converts to 0), and the + // far edge never passes the near one. Every edge then lies inside the frame, so the + // crop can't be larger than the frame and no subtraction can overflow. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let conv = |v: f64, side: i32| v.round().clamp(0.0, f64::from(side)) as i32; + let l = conv(x * scale, wi); + let t = conv(y * scale, hi); + let r = conv((x + rw) * scale, wi).max(l); + let b = conv((y + rh) * scale, hi).max(t); + return (t, l, hi - b, wi - r); } (self.top, self.left, self.bottom, self.right) } @@ -1116,7 +1138,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1649,8 +1671,8 @@ mod tests { #[test] fn hostile_roi_cannot_overflow_crop_math() { - // `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate, - // not overflow, and still yield a usable (degenerate) frame size. + // `-1e12` is clamped into the frame on conversion: it must not overflow and must still + // yield a usable (degenerate) frame size. let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); let rs = RenderSettings::default(); let mut m = OutputModule::for_format(OutputFormat::PngSequence); @@ -1663,6 +1685,66 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + let m = OutputModule::for_format(OutputFormat::PngSequence); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); + } + + #[test] + fn valid_wide_roi_endpoints_are_preserved() { + // A supported 30 000 x 100 frame at scale 1: ROI [10000, 0, 20000, 100] spans x 10 000..30 000 + // and must give 20 000 x 100 (the absolute right edge 30 000 exceeds the old +/-20 000 clamp). + let c = Comp::new(30_000, 100, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::for_format(OutputFormat::PngSequence); + m.crop = Crop { enabled: true, use_roi: true, roi: Some([10_000.0, 0.0, 20_000.0, 100.0]), ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (20_000, 100)); + assert_eq!(m.crop.edges(30_000, 100, 1.0), (0, 10_000, 0, 0)); + // Scale 4.0 pushes a valid edge to 120 000 px (30 000 x 4); the left edge 40 000 and the + // right edge 120 000 are both beyond 20 000 and must survive. + assert_eq!(m.crop.edges(120_000, 400, 4.0), (0, 40_000, 0, 0)); + assert_eq!(m.frame_size(120_000, 400, 4.0), (80_000, 400)); + // Hostile quads stay inside the frame: never empty, never larger than the frame, so the + // export can't be asked for an unbounded crop (an ROI reaching far outside the comp + // used to pad the frame instead). + let (fw, fh) = (1920, 1080); + let hostile = [ + [1e300, 1e300, 1e300, 1e300], + [-1e300, -1e300, 1e300, 1e300], + [-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0], + [-1e12, -1e12, -1e12, -1e12], + [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN], + [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY], + ]; + for roi in hostile { + m.crop.roi = Some(roi); + let (t, l, b, r) = m.crop.edges(fw, fh, 1.0); + assert!([t, l, b, r].iter().all(|e| *e >= 0), "{roi:?}: an edge pads the frame: {:?}", (t, l, b, r)); + let (w, h) = m.frame_size(fw, fh, 1.0); + assert!((1..=fw).contains(&w) && (1..=fh).contains(&h), "{roi:?}: {w}x{h} is outside 1x1..={fw}x{fh}"); + } + // An ROI covering more than the frame is the whole frame. + m.crop.roi = Some([-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0]); + assert_eq!(m.frame_size(fw, fh, 1.0), (fw, fh)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); @@ -1682,6 +1764,28 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap().format, f); } + #[test] + fn targa_sequence_format() { + let f = OutputFormat::from_name("tga").unwrap(); + assert_eq!(f, OutputFormat::TargaSequence); + assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f)); + assert_eq!(OutputFormat::from_name("Targa"), Some(f)); + assert_eq!(OutputFormat::from_name("Targa Sequence"), Some(f)); + assert_eq!(OutputFormat::from_path("/out/shot_00001.TGA"), Some(f)); + assert!(OutputFormat::ALL.contains(&f)); + assert!(f.is_sequence() && f.supports_alpha() && !f.supports_audio() && !f.is_movie()); + assert_eq!((f.label(), f.extension()), ("Targa Sequence", "tga")); + assert_eq!(f.coded_size(3, 5), (3, 5)); + // A movie name becomes a numbered sequence; RGB + Alpha survives the switch. + let mut m = OutputModule::for_format(OutputFormat::ProRes); + m.output = "/out/Comp 1.mov".into(); + m.channels = Channels::Rgba; + m.set_format(f); + assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1_[#####].tga", Channels::Rgba)); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap().format, f); + } + #[test] fn hevc_av1_codec_options() { assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc)); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 6187391ed..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { 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], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).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. diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 9f2e4e755..fd2fd183b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -23,6 +23,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa pub type Px = [f32; 4]; +/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000; +/// raster sits below it in the layering and may not depend on it, so the two must stay equal). +/// A padded side beyond the largest supported comp side has no render use. +pub const MAX_PADDED_SIDE: u32 = 30_000; + /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. @@ -161,13 +166,28 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side + /// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so + /// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side + /// already past the cap gets pad 0). Callers that track where the pixels went (a buffer + /// offset) must move by this, not by the requested pad. + pub fn effective_pad(&self, pad: u32) -> u32 { + pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2) + } + /// Copy with [`Image::effective_pad`] transparent pixels on every side. + /// + /// The clamp keeps the output within the largest image the engine supports (a max-size comp + /// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory + /// budget: callers that pad by a derived, possibly large amount bound the area themselves + /// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an + /// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let eff = self.effective_pad(pad); + let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; - let o = (y + pad as usize) * ow + pad as usize; + let o = (y + eff as usize) * ow + eff as usize; out.data[o..o + self.width as usize].copy_from_slice(src); } out @@ -222,7 +242,7 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { @@ -231,9 +251,20 @@ impl Image { o[3] = (a * 255.0 + 0.5) as u8; }) } + /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded. + pub fn to_rgba8_dithered(&self) -> Vec { + self.map_rgba8(|o, p, x, y| { + let a = p[3].clamp(0.0, 1.0); + let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; + for c in 0..3 { + o[c] = dither_u8(p[c] * inv, x, y); + } + o[3] = (a * 255.0 + 0.5) as u8; + }) + } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; @@ -241,13 +272,23 @@ impl Image { o[3] = 255; }) } - /// 8-bit RGBA with each pixel written by `f`, row-parallel. - fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]). + pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec { + self.map_rgba8(|o, p, x, y| { + let k = 1.0 - p[3].clamp(0.0, 1.0); + for c in 0..3 { + o[c] = dither_u8(p[c] + bg[c] * k, x, y); + } + o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec { let mut out = vec![0u8; self.data.len() * 4]; let w = self.width.max(1) as usize; - out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { - for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { - f(o, p); + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| { + for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() { + f(o, p, x as u32, y as u32); } }); out @@ -322,6 +363,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 { (h & 0x00ff_ffff) as f32 / 16_777_216.0 } +/// Seed of the output dither: fixed, so a frame converts to the same bytes every time. +const DITHER_SEED: u32 = 0x5bd1_e995; + +/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is +/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of +/// a smooth gradient average to the gradient instead of stepping in bands. A value already on +/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only +/// on the value and the pixel position, never on a clock or a global generator. +#[inline] +pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 { + let s = v.clamp(0.0, 1.0) * 255.0; + // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it. + let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4; + (s + t).floor() as u8 +} + /// A single-channel coverage mask. #[derive(Clone, Debug, Default, PartialEq)] pub struct Mask { @@ -346,6 +403,35 @@ impl Mask { mod tests { use super::*; + #[test] + fn dither_keeps_exact_codes_and_averages_the_rest() { + // A value on an 8-bit code is that code at every position. + for code in 0..=255u32 { + let v = code as f32 / 255.0; + for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] { + assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})"); + } + } + // Out-of-range and NaN values saturate instead of wrapping. + assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0)); + // A value between two codes lands on both, in proportion: the mean over a block is the value. + let v = (100.0 + 0.3) / 255.0; + let n = 64u32; + let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum(); + let mean = sum as f32 / (n * n) as f32; + assert!((mean - 100.3).abs() < 0.03, "{mean}"); + // Deterministic. + assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4)); + } + + #[test] + fn dithered_conversions_match_the_plain_ones_on_exact_codes() { + let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255]; + let img = Image::from_rgba8(2, 2, &src); + assert_eq!(img.to_rgba8_dithered(), img.to_rgba8()); + assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3])); + } + #[test] fn rgba8_roundtrip() { let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4]; @@ -384,4 +470,29 @@ mod tests { assert!((0.0..1.0).contains(&n)); } } + + #[test] + fn padded_bounds_hostile_pad_at_the_api() { + // Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an + // oversized buffer here — the very OOM this guard exists to stop, now caught in padded(). + // (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.) + let mut img = Image::new(29_990, 2); + img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(u32::MAX); + // Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12)); + // The source lands intact at the clamped offset, not the requested one. + assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent + } + + #[test] + fn padded_leaves_legit_pads_untouched() { + // Anti-over-clamp: an ordinary pad must round exactly, byte for byte. + let mut img = Image::new(8, 6); + img.set(3, 2, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(4); + assert_eq!((out.width, out.height), (16, 14)); + assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4) + } } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index accc1fc54..4f93e41d7 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -184,8 +184,9 @@ impl<'a> Renderer<'a> { pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { match &layer.source { LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; - let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + let a = self.footage.aux(item, f, ctx.source_time(layer))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -332,8 +333,8 @@ impl<'a> Renderer<'a> { } /// Draw a collapsed precomp layer into `canvas` on the CPU. - pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { - self.draw_collapsed(ctx, layer, item, canvas, false); + pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) { + self.draw_collapsed(ctx, layer, collapsed, canvas, false); } /// The layer's finished buffers (styles included) in the blending space. diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..ee19d8b25 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -4,10 +4,13 @@ //! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before //! its levels; Backwards plays the layer's [in, out) span reversed. //! -//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels -//! are evaluated once per block. To make the result independent of block boundaries, layer -//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's -//! tail is kept — so preview playback and export produce identical samples. +//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export). +//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects +//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every +//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the +//! result independent of block boundaries, layer effects are run from a cleared state over a +//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export +//! produce identical samples. //! //! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are //! silent for now. @@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 } +/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on +/// a grid of comp sample indices so the result does not depend on where a block starts. +fn param_step(rate: u32) -> i64 { + i64::from(rate / 1000).max(1) +} + +/// Does any property of the group change over time? +fn animated(g: &effectcraft_project::PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.is_animated() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) +} + fn sample_tick(i: i64, rate: u32) -> Tick { // Keep the requested sample when the rate does not divide the tick clock. let t = Tick::from_units(i, i64::from(rate)); @@ -189,21 +206,70 @@ impl MixEnv<'_> { /// `i0 .. i0 + n`. fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec { let t = sample_tick(i0, self.rate); - let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect(); + let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect(); if fx.is_empty() { return self.layer_raw(l, i0, n, false); } - let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum(); + let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); let pre = (pre_s * self.rate as f64).ceil() as usize; - let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed); - let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds(); - for (id, p) in &fx { - audio_fx::process(id, p, &mut buf, self.rate, start); + let b0 = i0 - pre as i64; + let mut buf = self.layer_raw(l, b0, n + pre, reversed); + let start = l.layer_time(sample_tick(b0, self.rate)).seconds(); + for (id, g, p) in &fx { + if audio_fx::per_sample(id) && animated(g) { + self.process_stepped(l, id, g, &mut buf, b0); + } else { + audio_fx::process(id, p, &mut buf, self.rate, start); + } } buf.drain(..pre * 2); buf } + + /// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter + /// grid, each with the parameters at its first sample. + fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) { + let step = param_step(self.rate); + let frames = buf.len() / 2; + let mut k = 0usize; + while k < frames { + let i = b0.saturating_add(k as i64); + let next = i.div_euclid(step).saturating_add(1).saturating_mul(step); + let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames); + let t = sample_tick(i, self.rate); + if let Some(piece) = buf.get_mut(k * 2..end * 2) { + audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds()); + } + k = end; + } + } + + /// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`. + /// Animated levels are evaluated on the parameter grid and interpolated in dB in between. + fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> { + let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] }; + let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2(); + let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)]; + if !pr.is_animated() && !pr.has_expression() { + return vec![gain(db(i0)); n]; + } + let step = param_step(self.rate); + let end = i0.saturating_add(n as i64); + let mut out = Vec::with_capacity(n); + let mut k = i0.div_euclid(step).saturating_mul(step); + let mut lo = db(k); + while k < end { + let hi = db(k.saturating_add(step)); + for i in k.max(i0)..k.saturating_add(step).min(end) { + let f = (i - k) as f64 / step as f64; + out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f])); + } + k = k.saturating_add(step); + lo = hi; + } + out + } } /// Shared inputs for composition audio mixing, including nested compositions. @@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut if a >= b { continue; } - let t_a = sample_tick(s0 + a as i64, rate); - let (ll, lr) = levels(&env, l, t_a); - if ll == 0.0 && lr == 0.0 { + let gains = env.level_gains(l, s0 + a as i64, b - a); + if gains.iter().all(|g| *g == [0.0, 0.0]) { continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { - out[(a + i) * 2] += f[0] * ll; - out[(a + i) * 2 + 1] += f[1] * lr; + for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() { + out[(a + i) * 2] += f[0] * g[0]; + out[(a + i) * 2 + 1] += f[1] * g[1]; } } } -fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) { - let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; - let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]); - (db_to_gain(a), db_to_gain(b)) -} - /// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per /// `bin` sample frames, `[min L, max L, min R, max R]`. pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 21f745c0e..1c7aadc55 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. +/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values +/// without editing the layer, so a key without them would reuse another instance's buffer. +fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) { + for o in ctx.property_overrides.iter().filter(|o| o.layer == id) { + o.prop.hash(h); + o.font.hash(h); + hash_debug(h, &o.value); + } + for m in ctx.media_overrides.iter().filter(|m| m.layer == id) { + m.item.hash(h); + } +} + fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { @@ -497,10 +510,11 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - // Time effects see property values at other times. Keyframes are part of the hashed - // structure (and the layer time is folded in), but expressions may read other layers at - // those times, which the key cannot see: such layers are not cached. - if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { + // Time effects (and CC Particle World's Birth Rate history) see property values at other + // times. Keyframes are part of the hashed structure (and the layer time is folded in), but + // expressions may read other layers at those times, which the key cannot see: such layers + // are not cached. + if history_unkeyable(layer, None) { return None; } key_with(ctx, layer, scale, draft, blur, false) @@ -510,7 +524,7 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { - if layer.switches.adjustment { + if layer.switches.adjustment || history_unkeyable(layer, Some(effects)) { return None; } let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; @@ -519,14 +533,26 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo Some(h.finish()) } -/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)? -fn reads_other_times(layer: &Layer) -> bool { +/// Does the layer run (among its first `effects` effects, all when `None`) an effect that reads +/// the layer at other times (Echo, Timewarp…) or its own parameters' history (CC Particle +/// World's Birth Rate, #1373)? +fn reads_other_times(layer: &Layer, effects: Option) -> bool { layer.switches.effects && layer.effects().is_some_and(|fx| { - fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time."))) + fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| { + g.enabled + && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } + if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect)) + }) }) } +/// The layer reads values at other times (see [`reads_other_times`]) and has expressions +/// (outside Transform), whose results at those times the key can't see. +fn history_unkeyable(layer: &Layer, effects: Option) -> bool { + reads_other_times(layer, effects) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) +} + /// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU /// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every /// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only. @@ -592,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { - let it = ctx.project.item(*item)?; + LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => { + let item = ctx.media_item(layer, *item); + let it = ctx.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. @@ -616,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); + // Collapsed-instance overrides replace values without editing the nested layer, so mix them + // into the key: a Font override, effect parameter or any other source-affecting override + // must not reuse another instance's processed buffer. + hash_overrides(&mut h, ctx, layer.id); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 483b19da3..1a550f9c7 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,11 +3,11 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { - let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) }; + let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer, None) }; deps.effects(ctx, layer, effects) } @@ -21,7 +21,7 @@ struct Dependencies<'a> { impl Dependencies<'_> { fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option) -> Option<()> { let previous = self.other_times; - self.other_times |= super::reads_other_times(layer); + self.other_times |= super::reads_other_times(layer, None); if layer.switches.effects && let Some(fx) = layer.effects() { @@ -63,19 +63,79 @@ impl Dependencies<'_> { Some(()) } + /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself + /// (geometry, materials, switches, in/out), its animated values and transform at the time, + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. + fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + self.remaining = self.remaining.checked_sub(1)?; + // Expressions sampled at other times: not cacheable, as in the ordinary path. + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { + return None; + } + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if let Some(item) = layer.source.item() { + hash_debug(self.h, &ctx.project.item(item)); + } + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. + hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); + hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); + hash_debug(self.h, &ctx.comp.draft_3d); + hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } + // Outside the layer's in/out range the read is transparent: fold the gate in, so a + // static model renders once inside it and again when it leaves. + layer.is_active_at(ctx.time).hash(self.h); + Some(()) + } + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { return None; } + // A referenced 3D model or primitive layer renders through the comp's camera and lights + // (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what + // moves its pixels, so the owner keeps a cache entry that changes when any of it does. + if layer.source.is_model() { + self.soloed_model(ctx, layer)?; + return Some(()); + } self.remaining = self.remaining.checked_sub(1)?; - if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; } self.path.push(id); // Include transforms/parents as well as pixels: selected-layer anchors and // continuously rasterized sources can depend on them. hash_debug(self.h, layer); + // A referenced layer's static values can be overridden by the collapsed instance without + // editing it (`hash_values` only sees animated ones), so the owner's key needs them too. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); // A cached owner never runs this layer's effects, so the comp camera and lights its @@ -88,6 +148,10 @@ impl Dependencies<'_> { } match layer.source { LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let item = match layer.source { + LayerSource::Footage { .. } => ctx.media_item(layer, item), + _ => item, + }; let source = ctx.project.item(item); hash_debug(self.h, &source); if let Some(source) = source { diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index ca5660a67..8d6761516 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -721,7 +721,7 @@ pub fn read_layer_entry(data: &[u8]) -> Option { /// The disk key of a segmentation: its chain key and scale. fn seg_key(key: u64, scale: f64) -> u128 { let mut h = Hash128::default(); - h.write(b"segmentation-v1"); + h.write(b"segmentation-v2"); h.write_u64(key); h.write_u64(scale.to_bits()); h.finish() @@ -841,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-2"; +const RENDER_VERSION: &str = "effectcraft-render-3"; fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); @@ -903,16 +903,26 @@ pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Opt /// Content key of a composition's pixels: project render settings, the comp and everything it /// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy /// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. +/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and +/// neither do markers unless an expression mentions them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { comp_content_key_with(project, comp, None) } /// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { + comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) +} + +fn comp_content_key_for_renderer( + project: &effectcraft_project::Project, + comp: effectcraft_project::ItemId, + virtual_id: Option>, + renderer: &str, +) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); - h.write(RENDER_VERSION.as_bytes()); + h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); let mut seen = std::collections::BTreeSet::new(); let mut stack = vec![comp]; @@ -933,13 +943,15 @@ pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effec } } } + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); h.write(it.name.as_bytes()); match &it.kind { - ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()), + ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)), k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { @@ -1070,6 +1082,28 @@ mod tests { assert!(dc.get_frame(42).is_none()); } + #[test] + fn previous_renderer_frames_are_not_reused() { + use effectcraft_project::{Comp, ItemKind, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut project = Project::default(); + let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0); + let dir = tmpdir("previous-renderer"); + { + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + } + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16]); + assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + } + #[test] fn persists_across_restarts_and_clears() { let dir = tmpdir("persist"); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..81fe45d6c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -39,17 +39,33 @@ pub struct EvalCtx<'a> { /// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's /// own source while rendering; `None` renders footage as transparent. pub footage: Option<&'a dyn crate::FootageSource>, + /// Property values overridden by the Essential Properties of a collapsed precomp instance. + pub property_overrides: &'a [crate::PropertyOverride], + /// Media Replacement values overridden by the Essential Properties of a collapsed precomp + /// instance. + pub media_overrides: &'a [crate::MediaOverride], } impl<'a> EvalCtx<'a> { pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // A collapsed precomp instance's Essential Property override replaces the base value; + // its expression (if any) still runs, and sees the overrides of this context. + if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + let v = o.value(layer, prop, self.time); + if prop.has_expression() + && let Some(h) = self.expr + { + return h.eval(self, layer, prop, &v).unwrap_or(v); + } + return v; + } // Separated Position reads as the combination of X/Y/Z Position. if prop.match_id == "position" && let Some(tr) = layer.transform() @@ -60,7 +76,13 @@ impl<'a> EvalCtx<'a> { let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); return Value::Vec3([self.value(layer, px).as_f64(), y, z]); } - let lt = layer.layer_time(self.time); + self.value_at_layer_time(layer, prop, layer.layer_time(self.time)) + } + /// [`Self::value`] with the keyframes read at layer time `lt` exactly, rather than at the + /// context time mapped through the layer's start and stretch (which rounds under a + /// fractional stretch: a Hold key at `lt` could read its previous value). An expression + /// still runs at the context time. + pub fn value_at_layer_time(&self, layer: &Layer, prop: &Property, lt: Tick) -> Value { let v = prop.value_at(lt); if prop.has_expression() && let Some(h) = self.expr @@ -139,13 +161,15 @@ impl<'a> EvalCtx<'a> { /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps /// with another pixel aspect) is stretched horizontally by this factor in the comp. pub fn par_ratio(&self, layer: &Layer) -> f64 { - let par = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { - Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, - Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, - Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, - _ => 1.0, - }, + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item), + _ => None, + }; + let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, _ => 1.0, }; let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; @@ -231,9 +255,27 @@ impl<'a> EvalCtx<'a> { (Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0) } } + /// The footage item a layer uses, honouring this instance's Media Replacement override. + pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId { + self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item) + } + + /// Size of a layer's source in layer pixels, honouring Media Replacement. + pub fn source_size(&self, layer: &Layer) -> (u32, u32) { + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item), + _ => None, + }; + match layer.source { + LayerSource::Null => (100, 100), + _ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)), + } + } + /// [`effect_bounds`] of a layer of this comp. pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) { - effect_bounds(self.project, self.comp, layer) + effect_bounds_of(self.comp, self.source_size(layer)) } /// Effect space (see [`effect_bounds`]) → comp matrix. pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 { @@ -242,6 +284,20 @@ impl<'a> EvalCtx<'a> { } } +impl crate::PropertyOverride { + fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + if self.font { + // A Font control merges only the font into the target text, as + // `essential::with_overrides` does. + let mut value = prop.value_at(layer.layer_time(time)); + effectcraft_project::essential::merge_font(&mut value, &self.value); + value + } else { + self.value.clone() + } + } +} + /// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and /// clamped to the comp, in seconds. pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { @@ -315,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option /// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer /// space for effect points: a shape layer's default effect point is its bounds' centre). pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) { - match source_size(project, layer) { + effect_bounds_of(comp, source_size(project, layer)) +} + +fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) { + match size { (0, _) => { let (w, h) = (comp.width as f64, comp.height as f64); ([w, h], [-w / 2.0, -h / 2.0]) diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 83f8cb5ac..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,8 +1,6 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{ - Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text, -}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. @@ -152,7 +150,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]), LayerSource::Camera | LayerSource::Light { .. } => None, _ => { - let (w, h) = source_size(ctx.project, layer); + let (w, h) = ctx.source_size(layer); (w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64]) } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 8366cf7c0..65bafe002 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let sub = self.r.nested(); + // A 3D model or primitive layer has no 2D source of its own: an effect referencing one + // reads it as After Effects does — the layer rendered on its own through the comp's + // camera (Create 3D Layer Instance's effect), in the layer's effect space (the + // comp, centred on the layer's anchor). + if other.source.is_model() { + let buf = sub.solo_3d(self.ctx, other); + return Some(self.pixels(other, buf, false)); + } // The cached layer buffer is shared; effects get their own copy. let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; Some(self.pixels(other, buf, true)) @@ -90,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let item = self.ctx.media_item(other, *item); + let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None }; if !f.has_audio { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(*item, f, st, frames, rate) + self.r.footage.audio(item, f, st, frames, rate) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { @@ -118,6 +127,19 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) } + fn param_at(&self, effect_index: usize, id: &str, layer_time: f64) -> Option { + // Only the one property: a simulation samples it at every step. Its keyframes are read + // at the requested layer time exactly (mapping it to comp time and back rounds under a + // fractional stretch, so a Hold key there could read its previous value). + let p = effect_param(self.layer, effect_index, id)?; + let lt = Tick::from_seconds_f64(layer_time); + Some(self.ctx.at(self.layer.comp_time(lt)).value_at_layer_time(self.layer, p, lt)) + } + + fn param_is_static(&self, effect_index: usize, id: &str) -> bool { + effect_param(self.layer, effect_index, id).is_some_and(|p| p.keys.is_empty() && !p.has_expression()) + } + fn comp_scene(&self) -> Option { Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin)) } @@ -140,6 +162,26 @@ impl EffectHost for FxHost<'_, '_, '_> { } } +/// Parameter `id` (a flattened id: match ids joined by `/`) of effect `effect_index` in +/// `layer`'s stack. +fn effect_param<'a>(layer: &'a Layer, effect_index: usize, id: &str) -> Option<&'a effectcraft_project::Property> { + let mut g = layer.effects()?.groups().nth(effect_index)?; + let mut path = id.split('/').peekable(); + while let Some(m) = path.next() { + if path.peek().is_none() { + return g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p), + _ => None, + }); + } + g = g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg), + _ => None, + })?; + } + None +} + /// The Essential Properties overrides of a precomp layer, evaluated at the context time. pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec { use effectcraft_project::essential; diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index cac2add43..ce0a2dcde 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -2,7 +2,7 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; use super::{ Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, - MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2, }; /// Blend policy for placing a layer, including optional output-depth quantization. @@ -52,7 +52,7 @@ impl<'a> Renderer<'a> { } } let t0 = web_time::Instant::now(); - let size = source_size(self.project, layer); + let size = ctx.source_size(layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let st = styles::render(ctx, layer, &content, layer_size); if let Some(t) = timing { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3dbfe2012..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; +pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index bb91ef71d..e6ba0a6fb 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; -use effectcraft_raster::{Mask, box_blur, gaussian_blur}; +use effectcraft_raster::{Mask, box_blur}; use rayon::prelude::*; use crate::eval::EvalCtx; @@ -60,40 +60,213 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu coverage_at(ctx, layer, g, buf) } +/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). +const MAX_FEATHER_PX: f64 = 1.0e6; +/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100, +/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask): +/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with +/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about +/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the +/// measured profile by 3/255 or more, this kernel by less than 1/255). +const SMOOTH_BOX_RADIUS: f64 = 0.37; +/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider +/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide +/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about +/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid +/// bounds the padded area, i.e. memory.) +const FULL_RES_RADIUS: usize = 48; +const COARSE_RADIUS: usize = 32; +/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened +/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the +/// buffer, is never dropped. +const MAX_GRID_PX: usize = 1 << 26; +/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer +/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel). +const MAX_COARSEN: usize = 1024; +/// Padding for a variable-width feather, which is rasterised at the buffer's resolution. +const MAX_VARIABLE_PAD: usize = 256; + +/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per +/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + step: usize, + pad: usize, + /// Radii of the box passes (Smooth: three; Linear: one, the others 0). + radii: [usize; 3], +} + +impl Axis { + /// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of + /// the path outside the buffer reaches into it as on an unbounded plane. + fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { + let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + let none = Axis { step: 1, pad: 0, radii: [0; 3] }; + if f <= 0.0 { + return none; + } + let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; + if linear { + // Linear falloff (not measured against After Effects): one box pass, a straight ramp + // `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider + // than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and + // resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp). + let r = (f / 2.0).round().max(1.0) as usize; + let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; + let rg = ((r as f64 / step as f64).round() as usize).max(1); + return Axis { step, pad: cap(rg), radii: [rg, 0, 0] }; + } + let r = (SMOOTH_BOX_RADIUS * f).round() as usize; + if r == 0 { + return none; + } + if full_res_only || r <= FULL_RES_RADIUS { + return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] }; + } + // On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to + // 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread + // the radii over the passes. + let want = 3 * (2 * r + 1); + let s0 = r.div_ceil(COARSE_RADIUS); + let fit = |s: usize| -> (usize, usize) { + let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize; + let got = (2 * sum + 3).saturating_mul(s); + (sum, got.abs_diff(want)) + }; + let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum)); + let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3)); + Axis { step, pad: sum + 1, radii } + } + + fn blurs(&self) -> bool { + self.radii.iter().any(|&r| r > 0) + } + + /// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring + /// axis keeps at least one grid pixel of radius), the padding still covering the support. + fn coarser(self, k: usize) -> Axis { + if k <= 1 { + return self; + } + let blurs = self.blurs(); + let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize); + if blurs && radii.iter().all(|&r| r == 0) { + radii[0] = 1; + } + let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1); + let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) }; + Axis { step: self.step.saturating_mul(k), pad, radii } + } + + /// Grid pixels for `n` buffer pixels. + fn len(&self, n: u32) -> Option { + (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) + } +} + +/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid +/// pixel centres (an exact copy when both steps are 1). +fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { + let (gw, gh) = (grid.width as usize, grid.height as usize); + let mut out = Mask::new(w, h, 0.0); + let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0); + // Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis. + let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) { + let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5; + let u = u.clamp(0.0, n.saturating_sub(1) as f64); + let i0 = u.floor() as usize; + (i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32) + }; + let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect(); + out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| { + let (y0, y1, fy) = map(y as u32, ay, gh); + for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) { + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + *o = top + (bottom - top) * fy; + } + }); + out +} + fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; - let path = effectcraft_path::to_kurbo(&sp); - let (w, h) = (buf.img.width, buf.img.height); let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale)); let exp = ctx.f(layer, g, "expansion", 0.0); let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + let feather = [feather[0] * buf.scale, feather[1] * buf.scale]; + Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX)) +} + +/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …) +/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even +/// the coarsest grid is too big). +fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> { + if !(ax.blurs() || ay.blurs()) { + return None; + } + let mut k = 1usize; + while k <= MAX_COARSEN { + let (cx, cy) = (ax.coarser(k), ay.coarser(k)); + if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h)) + && gw.checked_mul(gh).is_some_and(|n| n <= max_px) + && let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) + { + return Some((gw, gh, cx, cy)); + } + k = k.saturating_mul(2); + } + None +} + +/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp` +/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels. +fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask { + let path = effectcraft_path::to_kurbo(sp); // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); + // The feather blurs the mask's own edge, also where the path lies outside the layer (After + // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is + // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. + // A variable feather scales its point radii by the mean scale: it stays at full resolution + // (unless the grid has to be coarsened to fit `max_px`). + let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable)); + let blurred = ax.blurs() || ay.blurs(); + let grid = grid(w, h, ax, ay, max_px); + // Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step): + // blurred within the buffer at its resolution. + let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1]))); + // Buffer pixels -> grid pixels. + let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); + let mg = to_grid * *m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, exp, linear) } else if exp.abs() > 0.01 { // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a // ring around an uncovered middle (growing) or islands (shrinking). let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); - effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero) } else { - effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); - if feather[0] > 0.0 || feather[1] > 0.0 { - let img = cov.to_image(); - let b = if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); - box_blur(&img, r(feather[0]), r(feather[1]), 1, false) - } else { - gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false) - }; - cov = Mask::from_alpha(&b); + if blurred { + // Separable box passes (the corner of a feathered rect is the product of its edges). + let mut img = cov.to_image(); + for (rx, ry) in gx.radii.into_iter().zip(gy.radii) { + if rx > 0 || ry > 0 { + img = box_blur(&img, rx, ry, 1, false); + } + } + cov = Mask::from_alpha(&img); } - Some(cov) + if grid.is_some() { + cov = crop(&cov, gx, gy, w, h); + } + cov } /// The layer's enabled masks at the context time, flattened to polylines in layer space (for @@ -133,7 +306,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo if list.is_empty() { return false; } - // Masks may extend past the source: grow the buffer to cover them when feather/expansion pads. + // Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay + // empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside + // the buffer still reaches into it (`coverage_at`). let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; @@ -169,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo buf.img.mul_mask(&acc); true } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_keyframe::ShapePath; + + /// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded + /// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask + /// just outside the layer covered nothing and the whole layer vanished. The grid is now + /// coarsened instead and keeps a pad that covers the blur. + #[test] + fn a_grid_too_big_is_coarsened_and_keeps_its_padding() { + let a = Axis::new(40.0, false, false); + assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit"); + let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid"); + assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX); + for g in [gx, gy] { + assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}"); + // The pad covers the blur's support (the three boxes' radii) in buffer pixels. + assert!(g.pad * g.step >= g.radii.iter().sum::() * g.step, "{g:?}"); + } + // A grid that fits is not touched. + assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a)); + } + + /// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit + /// just below the full-resolution padded grid. The Add mask lies just right of the buffer + /// (x 210..260); its feather still fades into the layer, close to the full-resolution result. + #[test] + fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() { + let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0); + let m = Mat3::IDENTITY; + let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX); + let a = Axis::new(40.0, false, false); + let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1; + let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit); + let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0); + assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50)); + assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50)); + for x in [150, 170, 180, 190, 199] { + assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50)); + } + assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear"); + } +} diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index ec89f7a80..9b428d1a0 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -7,6 +7,8 @@ use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, }; +use effectcraft_keyframe::Value; +use effectcraft_project::LayerId; /// Coordinate space and stack range shared by CPU and accelerated effect evaluation. #[derive(Clone, Copy)] @@ -16,6 +18,88 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A property value an Essential Properties override gives a layer of a collapsed precomp. +#[derive(Clone, Debug)] +pub struct PropertyOverride { + pub(crate) layer: LayerId, + pub(crate) prop: u64, + pub(crate) value: Value, + /// A Font control changes only the font of the target text, not its text. + pub(crate) font: bool, +} + +/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed +/// precomp. +#[derive(Clone, Copy, Debug)] +pub struct MediaOverride { + pub(crate) layer: LayerId, + pub(crate) item: ItemId, +} + +impl MediaOverride { + /// A Media Replacement of `layer`'s footage by `item`. + pub fn new(layer: LayerId, item: ItemId) -> Self { + MediaOverride { layer, item } + } +} + +/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it. +pub struct Collapsed { + pub item: ItemId, + pub(crate) property_overrides: Vec, + pub(crate) media_overrides: Vec, +} + +/// The property and media overrides of a collapsed precomp layer. `None` when its Essential +/// Properties group or the source comp's definitions are missing (the flattened path then +/// preserves the old behaviour). +fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec, Vec)> { + use effectcraft_project::essential::{self, ControlType, EgKind}; + let over = essential::overridden(layer); + if over.is_empty() { + return Some((vec![], vec![])); + } + let g = essential::group(layer)?; + let eg = ctx.project.comp(item)?.essential.as_ref()?; + let mut property = vec![]; + let mut fonts: Vec = vec![]; + let mut media = vec![]; + g.walk("", &mut |_, p| { + if !over.contains(&p.uid) { + return; + } + let Some(control) = essential::control_of(&p.match_id) else { return }; + let Some(ctl) = eg.resolve(control) else { return }; + let value = ctx.value(layer, p); + match &ctl.kind { + EgKind::Property { .. } => { + let font = ctl.as_type == Some(ControlType::Font); + for (layer, prop) in ctl.kind.targets() { + let o = PropertyOverride { layer, prop, value: value.clone(), font }; + if font { fonts.push(o) } else { property.push(o) } + } + } + EgKind::Media { layer: target } => { + let Value::Scalar(id) = value else { return }; + let item = ItemId(id.max(0.0) as u64); + if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + media.push(MediaOverride { layer: *target, item }); + } + } + _ => {} + } + }); + // A Font control merges into the text its instance shows, after a whole-value (Text) override + // of the same property, as `essential::with_overrides` does: keep one entry per property. + for f in fonts { + match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) { + Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value), + None => property.push(f), + } + } + Some((property, media)) +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { @@ -122,8 +206,10 @@ impl<'a> Renderer<'a> { /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer - /// styles (those force a flattened render of the precomp, as in After Effects). - pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// styles (those force a flattened render of the precomp, as in After Effects). Essential + /// Properties overrides travel with it and are applied as the nested layers are evaluated, + /// so the nested comp is not rasterised at its frame. + pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { return None; } @@ -140,24 +226,37 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } - // Instance overrides (Essential Properties) need the nested comp rendered on its own. - if !effectcraft_project::essential::overridden(layer).is_empty() { + // A composition proxy or frame blending is drawn by the flattened path (`precomp_source`); + // collapsing renders the nested comp directly and would ignore both. + if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off { return None; } - Some(*item) + let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?; + Some(Collapsed { item: *item, property_overrides, media_overrides }) } /// A renderer and context that draw a collapsed precomp's layers straight into this comp: /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 3D layers use this comp's camera and lights. - pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)> + where + 'a: 'p, + { + let item = collapsed.item; let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { return None; } - let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; + let nctx = EvalCtx { + comp_id: item, + comp: nc, + time: t, + property_overrides: &collapsed.property_overrides, + media_overrides: &collapsed.media_overrides, + ..*ctx + }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -189,19 +288,19 @@ impl<'a> Renderer<'a> { /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` /// quantise their own footprints). - pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { + pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); @@ -230,6 +329,7 @@ impl<'a> Renderer<'a> { let continuous = match layer.source { LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { + let item = ctx.media_item(layer, item); layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } _ => false, @@ -267,7 +367,7 @@ impl<'a> Renderer<'a> { } pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) } + EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } } /// This renderer with another accelerator (or none): the passes of @@ -406,7 +506,7 @@ impl<'a> Renderer<'a> { blank = false; } else { let changed = match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank), None => self.draw_layer(ctx, visible[i], canvas, blank), }; i += 1; @@ -594,8 +694,10 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - // A composition proxy (a rendered still or movie) stands in for the comp. - // Nothing before the nested comp starts or after it ends. + // A composition proxy (a rendered still or movie) stands in for the comp. It is + // decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear + // Light settings and the layer's Frame Blending apply. Nothing before the nested + // comp starts or after it ends. let lt = ctx.nested_time(layer); if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) { return None; @@ -603,7 +705,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = cineon_frame(self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?, pf); + let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -635,7 +737,8 @@ impl<'a> Renderer<'a> { Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { - let it = self.project.item(*item)?; + let item = ctx.media_item(layer, *item); + let it = self.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; if !f.has_video { return None; @@ -643,7 +746,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(*item, f, k) { + if let Some(img) = self.footage.vector_frame(item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -653,10 +756,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(*item); + let proxy = self.proxy_for(item); let pf = proxy.unwrap_or(f); let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), @@ -738,6 +841,10 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + (f.alpha as u8).hash(&mut h); + if f.alpha == effectcraft_project::AlphaMode::Premultiplied { + f.premul_color.map(f32::to_bits).hash(&mut h); + } if f.cineon_applies() { let c = &f.cineon; [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); @@ -836,6 +943,30 @@ impl<'a> Renderer<'a> { self.source(ctx, layer) } + /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an + /// effect layer parameter reads when it references one, as After Effects, which renders 3D + /// layers through the active camera. The comp's pixels at this render's scale; the layer's + /// blend mode, track matte and video switch don't apply (a source read, not a composite — + /// the solo clone of a 3D track matte, see `three_d::adv`). + pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf { + let s = self.opts.scale; + let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1); + let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1); + let mut canvas = Image::new(w, h); + // Outside the layer's in/out points there is nothing to read: the copy is transparent + // there, like any other layer reference (the 3D pipelines don't gate on the range). + if !layer.is_active_at(ctx.time) { + return Buf { img: canvas, offset: [0.0; 2], scale: s }; + } + let mut solo = layer.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas); + Buf { img: canvas, offset: [0.0; 2], scale: s } + } + /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 511b32f3e..67f224793 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -396,7 +396,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; + // Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per + // copy, so the last copy stops one step short of End Opacity (After Effects + // 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %). + let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 }; let opk = so + (eo - so) * t; let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 }; for d in &base_draws { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 2ca03b4f6..e6f6cba7c 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() { assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img)); } +/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies +/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 % +/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows +/// only the last copy, only the middle one, and both. +#[test] +fn repeater_copy_opacity_matches_after_effects() { + let (p, cid) = shape_comp_sized(1920, 1080, |ids| { + let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]); + set(&mut rep, "transform/rotation", Value::Scalar(10.0)); + set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0])); + set(&mut rep, "transform/endOpacity", Value::Scalar(40.0)); + let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep]; + vec![build::shape_group(ids, "Group 1", items)] + }); + let img = frame(&p, cid); + // AE's green over its red layer, 153, 153, 204, 235 and 255 of 255. + for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] { + let a = alpha(&img, x, y); + assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}"); + } +} + #[test] fn repeater_then_merge_unites_copies() { let (p, cid) = shape_comp(|ids| { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 079274499..38baa8f3e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -16,6 +16,7 @@ mod frame_blend; mod guides; mod mask_blur; mod mask_expansion; +mod mask_feather; mod motion_tile; mod nested; mod paint; @@ -346,6 +347,208 @@ fn particle_world_follows_the_comp_camera() { assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}"); } +/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a +/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. +fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { + burst_scene_keys(cut, start, stretch, false) +} + +/// [`burst_scene`]; `hold`: Hold keys, 3 until `cut` and 0 from `cut` exactly. +fn burst_scene_keys(cut: Option, start: f64, stretch: f64, hold: bool) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))]; + add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals); + if let Some(cut) = cut { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let fx = fx.unwrap(); + let rate = fx.prop_mut("birthRate").unwrap(); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + rate.keys = if hold { vec![key(0.0, 3.0).hold(), key(cut, 0.0).hold()] } else { vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)] }; + } + l.start_time = Tick::from_seconds_f64(start); + l.in_point = l.start_time; + l.out_point = Tick::from_seconds_f64(4.0); + l.stretch = stretch; + p.comp_mut(cid).unwrap().layers = vec![l]; + (p, cid) +} + +/// Total light in a frame. +fn light(img: &crate::Image) -> f32 { + img.data.iter().map(|p| p[0] + p[1] + p[2]).sum() +} + +/// #1373: a Birth Rate keyed down to 0 stops new births; the particles already born live on +/// until their Longevity runs out (they vanished the frame the rate reached 0). +#[test] +fn particle_world_keeps_a_burst_after_birth_rate_drops_to_zero() { + let (keyed, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (constant, _) = burst_scene(None, 0.0, 100.0); + let at = Tick::from_seconds_f64; + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), "during the burst"); + let (k, c) = (light(&render_cached(&keyed, cid, at(0.9), None)), light(&render_cached(&constant, cid, at(0.9), None))); + assert!(k > 1.0 && k < c, "the burst is still drawn, with no new births: {k} vs {c}"); + // Longevity 1 s (± 20 %): gone by 1.9 s. + assert_eq!(light(&render_cached(&keyed, cid, at(1.9), None)), 0.0); +} + +/// The burst's history: moving an earlier key changes a later frame whose Birth Rate is the +/// same (0), cached or not, and offset or stretched layers sample their own layer times. +#[test] +fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() { + let at = Tick::from_seconds_f64; + let (long, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (short, _) = burst_scene(Some(0.25), 0.0, 100.0); + let cache = crate::LayerCache::default(); + let a = render_cached(&long, cid, at(0.9), Some(&cache)); + let b = render_cached(&short, cid, at(0.9), Some(&cache)); + assert_same(&b, &render_cached(&short, cid, at(0.9), None), "cached render after an earlier key moved"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "a shorter burst: {} vs {}", light(&b), light(&a)); + let plain = render_cached(&long, cid, at(0.9), None); + let (late, _) = burst_scene(Some(0.5), 0.5, 100.0); + assert_same(&render_cached(&late, cid, at(1.4), None), &plain, "a layer starting at 0.5 s"); + let (slow, _) = burst_scene(Some(0.5), 0.0, 200.0); + assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %"); +} + +/// The Birth Rate history is read at exact layer times: under a fractional stretch, mapping a +/// layer time to comp time and back rounds down a tick, so a Hold cut at 0.5 s read its +/// previous value at the step starting there (six extra births). +#[test] +fn particle_world_reads_a_hold_cut_at_its_exact_layer_time_under_any_stretch() { + use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer}; + let plain = { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, 100.0, true); + render_cached(&p, cid, Tick::from_seconds_f64(0.9), None) + }; + assert!(light(&plain) > 1.0, "the burst is drawn"); + for stretch in [33.3, 133.33, 200.0] { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, stretch, true); + let c = p.comp(cid).unwrap(); + let l = &c.layers[0]; + let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); + let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let host = FxHost { r: &renderer, ctx: &ctx, layer: l, origin: [0.0; 2], index: Default::default(), original: None }; + let rate = |t: f64| host.param_at(0, "birthRate", t).unwrap().as_f64(); + assert_eq!((rate(0.5 - 1.0 / 60.0), rate(0.5)), (3.0, 0.0), "stretch {stretch} %"); + assert!(!host.param_is_static(0, "birthRate")); + let t = l.comp_time(Tick::from_seconds_f64(0.9)); + assert_same(&render_cached(&p, cid, t, None), &plain, &format!("stretch {stretch} %")); + } +} + +/// An expression that reads a Controller layer's Opacity (÷ 25) before layer time 0.5 s and is +/// 0 after (a stand-in for the expression engine). +struct ControllerRate; + +impl crate::eval::ExprHost for ControllerRate { + fn eval(&self, ctx: &crate::eval::EvalCtx, layer: &effectcraft_project::Layer, _: &effectcraft_project::Property, _: &Value) -> Result { + if layer.layer_time(ctx.time).seconds() >= 0.5 { + return Ok(Value::Scalar(0.0)); + } + let c = ctx.comp.layers.iter().find(|l| l.name == "Controller").ok_or("no controller")?; + let opacity = c.props.prop("transform/opacity").ok_or("no opacity")?; + Ok(Value::Scalar(ctx.value(c, opacity).as_f64() / 25.0)) + } +} + +/// A Birth Rate expression reading another layer: changing that layer changes the history but +/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. +/// CC Particle World layers with expressions therefore aren't layer cached. +#[test] +fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() { + let scene = |opacity: f64| { + let (mut p, cid) = burst_scene(None, 0.0, 100.0); + let comp = p.comp(cid).unwrap().clone(); + let mut ctl = solid(&mut p, &comp, [0.0, 0.0, 0.0], 10, 10); + ctl.name = "Controller".into(); + ctl.switches.video = false; + ctl.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity); + let c = p.comp_mut(cid).unwrap(); + let fx = c.layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "controller".into(), enabled: true }); + c.layers.push(ctl); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.expr = Some(&ControllerRate); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(0.9)) + }; + let ((many, cid), (few, _)) = (scene(75.0), scene(25.0)); + let cache = crate::LayerCache::default(); + let a = render(&many, cid, Some(&cache)); + let b = render(&few, cid, Some(&cache)); + assert_same(&b, &render(&few, cid, None), "cached render after the controller changed"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); +} + +/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems +/// II, CC Mr. Mercury): Birth Rate `rate`, +/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s. +fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); + let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))]; + add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals); + if rate.is_none() { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)]; + } + p.comp_mut(cid).unwrap().layers = vec![l]; + (p, cid) +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst +/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate +/// of 0 throughout), and draws as nothing born once Longevity has run out. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() { + let at = Tick::from_seconds_f64; + for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (keyed, cid) = system_scene(id, None); + let (constant, _) = system_scene(id, Some(3.0)); + let (none, _) = system_scene(id, Some(0.0)); + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst")); + let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None)); + assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z)); + assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity")); + } +} + +/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the +/// layer cache (its key can't see what the expression returned at earlier times); without one +/// the layer is cached. +#[test] +fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() { + for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (mut p, cid) = system_scene(id, Some(2.0)); + let key = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false) + }; + assert!(key(&p).is_some(), "{id}: cached without an expression"); + let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true }); + assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression"); + } +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { @@ -1073,3 +1276,194 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() { assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); assert_eq!(cache.stats().entries, 1); } + +// ------------------------------------------------------------------------------ 3D layer instances + +/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a +/// data URI), parsed in memory: a FootageSource that knows one model item. +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +struct SoloModels; + +impl crate::FootageSource for SoloModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the +/// copy: a solid with a 3D Layer Instance effect referencing the model. +fn instance_scene(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + // Models are Advanced 3D (layer.newModel switches a Classic comp over). + p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + let mid = p.add_item( + "Triangle", + Label::Red, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "Triangle.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }), + ); + let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model]; + (p, cid) +} + +fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not. +fn cover_x(img: &crate::Image) -> f32 { + let (mut m, mut sx) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let a = img.get(x as i64, y as i64)[3]; + m += a; + sx += a * x as f32; + } + } + assert!(m > 1.0, "the triangle is drawn"); + sx / m +} + +/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the +/// referenced model's solo render depends on — the camera, and the model's in/out gate. +#[test] +fn an_instance_of_a_model_is_cached_and_follows_the_camera() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let (q, _) = instance_scene(40.0); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key"); + // The same layer at a later time, outside its in/out range: the read turns transparent. + let mut out = p.clone(); + out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5); + assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key"); + // Two renders of one time: the second is served from the cache. + let cache = crate::LayerCache::default(); + let here = render_models(&p, cid, t, Some(&cache)); + let again = render_models(&p, cid, t, Some(&cache)); + assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one"); + assert!(cache.stats().hits > 0, "the copy was served from the cache"); + // Panning the camera: the copy (and the model) move with it. + let panned = render_models(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (cover_x(&here), cover_x(&panned)); + assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); +} + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +} diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..242a5a5e2 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -148,6 +148,42 @@ fn stereo_mixer_pans() { assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]); } +/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s. +fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) { + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() }; + g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)]; +} + +/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples. +fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec { + fn half(_: f64) -> (f32, f32) { + (0.5, 0.5) + } + let mut m = vec![]; + for k in 0..SR as usize / block { + m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block)); + } + m.iter().step_by(2).copied().collect() +} + +/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one +/// second at a time), instead of holding the value at the block's first sample. +#[test] +fn animated_levels_ramp_within_a_block() { + let (mut levels, lc) = comp_with(None, &[], false); + key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0])); + let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false); + key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0)); + for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] { + let one = left_ramp(p, cid, SR as usize); + assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic"); + assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]); + assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]); + assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result"); + } +} + #[test] fn tone_makes_a_solid_audible_at_its_frequency() { let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))]; diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..eb340a4ca 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -3,7 +3,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() { assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); } +/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer +/// renders the nested comp directly and ignores both, so such a layer must not collapse. +#[test] +fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() { + let (mut p, cid) = scene(true, false); + let id = p.comp(cid).unwrap().layers[0].id; + let collapses = |p: &Project| { + let r = Renderer::new(p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO); + r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some() + }; + assert!(collapses(&p), "a plain precomp collapses"); + // Frame blending needs both the comp's switch and the layer's. + p.comp_mut(cid).unwrap().enable_frame_blending = true; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix; + assert!(!collapses(&p), "frame blending keeps the flattened path"); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + // A composition proxy stands in for the precomp's pixels. + let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") }; + let proxy = + effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true }; + p.item_mut(item).unwrap().proxy = Some(Box::new(proxy)); + assert!(!collapses(&p), "a proxy keeps the flattened path"); +} + +/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed +/// instances give A different static overrides (`hash_values` only sees animated values). +#[test] +fn referenced_layer_overrides_change_the_owner_cache_key() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]); + let aid = a.id; + let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]); + let bid = b.id; + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]); + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0)); + p.next_id = next; + b.props.sub_mut("effects").unwrap().children.push(fx.into()); + let mut c = comp; + c.layers = vec![b, a]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let comp = p.comp(cid).unwrap(); + let b = comp.layer(bid).unwrap(); + let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = |value: f64| { + let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; + let ctx = + EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + crate::cache::layer_key(&ctx, b, 1.0, false, false) + }; + let (opaque, half) = (key(100.0), key(50.0)); + assert!(opaque.is_some(), "the owner is cacheable"); + assert_ne!(opaque, half, "A's static override changes B's key"); +} + #[test] fn collapsed_3d_layers_join_the_parent_scene() { // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a diff --git a/crates/render/src/tests/mask_feather.rs b/crates/render/src/tests/mask_feather.rs new file mode 100644 index 000000000..db04b1f9f --- /dev/null +++ b/crates/render/src/tests/mask_feather.rs @@ -0,0 +1,251 @@ +//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather +//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the +//! coverage stopping at the layer edge. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) +} + +fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) { + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]); + p.next_id = next; + m.get_mut("feather").unwrap().value = Value::Vec2(feather); + if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind { + *feather_falloff = FeatherFalloff::Linear; + } + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); +} + +/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide +/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w). +fn edge(d: f64, w: f64) -> f64 { + let t = (d / w + 1.5).clamp(0.0, 3.0); + if t < 1.0 { + t * t * t / 6.0 + } else if t < 2.0 { + (-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0 + } else { + 1.0 - (3.0 - t).powi(3) / 6.0 + } +} + +/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`. +fn window(feather: f64) -> f64 { + 2.0 * (0.37 * feather).round() + 1.0 +} + +/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis). +fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 { + let w = window(feather); + edge(x - a, w) + edge(b - x, w) - 1.0 +} + +/// A white 200×100 solid (32 bpc) with one Add mask. +fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100); + add_mask(&mut p, &mut l, rect, feather, linear); + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, scale) +} + +#[test] +fn a_feathered_mask_outside_a_solid_reaches_into_it() { + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes. + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); + for x in [150i64, 170, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0); + let got = img.get(x, 50)[3] as f64; + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}"); + } + assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); + // Finite support: nothing beyond 1.5 windows (46.5 px) from the edge. + assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0); + // Without feather the mask hides the layer. + let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); + assert!(hard.data.iter().all(|p| p[3] == 0.0)); + // A linear falloff ramps over the feather width: 20 px either side of the edge at x 210. + let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0); + let a = lin.get(199, 50)[3]; + assert!((a - 0.24).abs() < 0.04, "linear: {a}"); + assert!(lin.get(185, 50)[3] < 0.01); + // At half resolution the same layer pixel gets the same coverage. + let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5); + assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]); +} + +#[test] +fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() { + // The mask covers the layer and 100 px around it: the layer edges are inside the mask and + // stay opaque (they used to fade, as if the layer edge were the mask edge). + let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0); + for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] { + assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]); + } + // A mask edge inside the layer is feathered as before: half coverage on the edge. + let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0); + assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]); + assert!(inner.get(100, 50)[3] > 0.98); +} + +#[test] +fn huge_and_broken_feathers_stay_bounded() { + for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] { + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}"); + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}"); + } +} + +/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a +/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and +/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000. +fn case_398(with_mask: bool) -> (Project, ItemId) { + let mut p = Project::default(); + assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth"); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080); + adj.switches.adjustment = true; + let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap(); + let mut next = p.next_id; + let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + adj.props.sub_mut("effects").unwrap().children.push(ramp.into()); + if with_mask { + add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false); + } + let c = p.comp_mut(cid).unwrap(); + c.layers.push(adj); + c.layers.push(red); + (p, cid) +} + +fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] { + let px = img.get(x, y); + [0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32) +} + +#[test] +fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = case_398(true); + let img = render_frame(&p, cid, t, 1.0); + // The ramp alone (no mask): what the adjustment layer mixes in. + let (q, qid) = case_398(false); + let ramp = render_frame(&q, qid, t, 1.0); + // After Effects 2026, 8 bpc, the render of 398 at 1 s. + let ae = [ + ((960, 0), [255, 0, 0]), + ((960, 300), [254, 0, 0]), + ((960, 540), [250, 5, 5]), + ((960, 800), [248, 20, 20]), + ((960, 1000), [252, 40, 40]), + ((960, 1079), [255, 47, 47]), + ((100, 1079), [255, 29, 29]), + ]; + let mut report = Vec::new(); + for ((x, y), want_ae) in ae { + let got = rgb8(&img, x, y); + // 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000), + // blurred from the mask edge outside the layer (on the coarse grid); the result is the + // red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0); + let r = ramp.get(x, y); + let want = [0, 1, 2].map(|k| { + let red = if k == 0 { 1.0 } else { 0.0 }; + ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 + }); + for k in 0..3 { + assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}"); + } + // 2. After Effects: within 2 levels per channel. + for k in 0..3 { + assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + } + report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); + } + println!("398: {}", report.join("; ")); + // The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red. + assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); + assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); +} + +/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at +/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from +/// x 160 to 760 in steps of 20 (8-bit values / 255). +const AE_FEATHER_100: [(i64, f64); 11] = + [(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)]; +const AE_FEATHER_300: [(i64, f64); 31] = [ + (160, 0.0), + (180, 0.004), + (200, 0.008), + (220, 0.012), + (240, 0.024), + (260, 0.035), + (280, 0.055), + (300, 0.078), + (320, 0.11), + (340, 0.149), + (360, 0.192), + (380, 0.247), + (400, 0.306), + (420, 0.369), + (440, 0.435), + (460, 0.502), + (480, 0.569), + (500, 0.635), + (520, 0.698), + (540, 0.757), + (560, 0.808), + (580, 0.851), + (600, 0.89), + (620, 0.922), + (640, 0.945), + (660, 0.965), + (680, 0.976), + (700, 0.988), + (720, 0.992), + (740, 0.996), + (760, 1.0), +]; + +#[test] +fn the_smooth_feather_falloff_matches_after_effects() { + for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let mut worst = 0.0f64; + for &(x, want) in ae { + let got = img.get(x, 540)[3] as f64; + // Tolerance 2/255 on the 8-bit After Effects values. + assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}"); + worst = worst.max((got - want).abs()); + } + // The corner is the product of its two edges (a separable blur): 0.25 for both feathers. + let corner = img.get(460, 240)[3] as f64; + assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}"); + println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0); + } +} diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 8739140de..e9599f70b 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() { } let key = |p: &Project| { let comp = p.comp(cid).unwrap(); - let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(0.5), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) }; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e1954b597..c25b1da8d 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -694,10 +695,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. + pen += x.track_before; shift[gi] = pen; - pen += x.track_after * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; @@ -805,9 +808,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index ea89e37b0..4bf459142 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -220,9 +220,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -262,10 +262,10 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; - let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -277,11 +277,36 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } +/// #1798: After Effects adds Tracking Amount in absolute pixels between neighbouring characters, +/// independent of font size. +#[test] +fn text_animator_tracking_amount_uses_pixels_independent_of_font_size() { + let width_diff = |font_size: f64, amount: f64| { + let (mut p, cid, comp) = setup(2000, 400); + let mut l = text_layer(&mut p, &comp, doc("ABCDE", font_size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let w = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])).width(); + let (mut p0, cid0, comp0) = setup(2000, 400); + let l0 = text_layer(&mut p0, &comp0, doc("ABCDE", font_size)); + p0.comp_mut(cid0).unwrap().layers.push(l0); + let w0 = bounds_of(&glyph_paths(&ctx(&p0, cid0, 0.0), &p0.comp(cid0).unwrap().layers[0])).width(); + w - w0 + }; + // 5 characters = 4 gaps. In After Effects, Tracking Amount 100 adds 100 px per gap = 400 px total width delta, + // regardless of whether font size is 50 px or 200 px. + let diff_50 = width_diff(50.0, 100.0); + let diff_200 = width_diff(200.0, 100.0); + assert!((diff_50 - 400.0).abs() < 1.0, "font_size 50 px width delta: {diff_50}"); + assert!((diff_200 - 400.0).abs() < 1.0, "font_size 200 px width delta: {diff_200}"); +} + #[test] fn anchor_grouping_line_rotates_around_line_centre() { let (mut p, cid, comp) = setup(600, 300); @@ -479,6 +504,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..2162439ec 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } - if let Some(item) = r.collapsed(ctx, layer) { - collapsed_layer(r, ctx, layer, item, b); + if let Some(collapsed) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, &collapsed, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) @@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). -fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 0ebd0b1ac..19548b224 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -228,6 +228,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four +/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the +/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's +/// map position, so a position that differs between backends in its last bits moves the +/// taps by an amount proportional to that difference and does not switch them to another +/// cell's jitter. +fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) { + let corner = |dx: i32, dy: i32| -> (f32, f32) { + let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919))); + (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5) + }; + let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)); + let ax = c00.0 + (c10.0 - c00.0) * wx; + let bx = c01.0 + (c11.0 - c01.0) * wx; + let ay = c00.1 + (c10.1 - c00.1) * wx; + let by = c01.1 + (c11.1 - c01.1) * wx; + (ax + (bx - ax) * wy, ay + (by - ay) * wy) +} + /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). @@ -243,8 +262,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's -/// texel footprint, each tap jittered by an integer hash of the receiver's -/// sub-texel cell and the tap index, and each tap comparing against the four +/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the +/// receiver's sub-texel lattice and the tap index, blended across the receiver's +/// lattice cell), and each tap comparing against the four /// nearest texel centres with bilinear weights. The jitter detaches the tap grid /// from the texel axes (axis-aligned binary taps are what stair-step a straight /// shadow edge), and keying it below texel size keeps the dither finer than an @@ -312,13 +332,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); let step = reach / (n - 1) as f32; let half = (n - 1) as f32 * 0.5; - let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); + let (gu, gv) = (u * 8.0, v * 8.0); + let (cx, cy) = (gu.floor() as i32, gv.floor() as i32); + let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.0; for j in 0..n { for i in 0..n { - let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); - let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; - let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index f9a0f2066..627e4e291 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() { set(l, "geometryOptions/bevelStyle", Value::Enum(1)); set(l, "transform/rotationY", Value::Scalar(40.0)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -887,6 +923,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test] +fn penumbra_jitter_is_continuous_across_its_lattice() { + // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice, + // across u and across v, inside the penumbra. The GPU places a receiver within a few + // ulps of the CPU, so coverage that jumps at a lattice line differs between the two + // backends there; both sides of each line must agree within one 8-bit level. + let (m, texels) = wall_map(64, 1.0, 6.0); + let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0); + let mut worst = 0.0f32; + for k in 0..16 { + let line = k as f32 * 0.125; + for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] { + let (fa, fb) = (f(a), f(b)); + assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}"); + worst = worst.max((fa - fb).abs()); + } + } + assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line"); +} + #[test] fn penumbra_width_is_independent_of_map_resolution() { // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 12bcbcf37..098f095d0 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { return bokeh_blur(img, iris, levels[0] as f64); } let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); - let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + // A pixel blends only the two levels around its radius, so the levels are made in order and + // only that pair is held: peak memory stays a few images whatever the level count, rather + // than one image per level. Each level's blur is parallel over its rows. let mut out = Image::new(img.width, img.height); - out.data.par_iter_mut().enumerate().for_each(|(i, o)| { - let r = radius[i]; - let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); - let k = (f.floor() as usize).min(n - 2); - let t = f - k as f32; - let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); - *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); - }); + let mut below = bokeh_blur(img, iris, lo as f64); + for (k, &r) in levels.iter().enumerate().skip(1) { + let above = bokeh_blur(img, iris, r as f64); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + if kk + 1 == k { + let t = f - kk as f32; + let (a, b) = (below.data[i], above.data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + } + }); + below = above; + } out } diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 30873b6a9..a927e2ceb 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -241,6 +242,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { .filter(|k| **k <= ctx.time) .find_map(|&k| dir(pos(k) - pos(k - dt))) .or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k)))) + .or_else(|| { + // An expression has no keys to fall back on: sample it instead. + let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression())); + expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten() + }) +} + +/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame. +const MOTION_SEARCH_STEPS: u32 = 12; + +/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a +/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away +/// (within the layer's in/out points) until the position differs from the current one, then +/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the +/// one at the end of the motion whichever frame is shown. +fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option { + let here = pos(ctx.time); + let moved = |t| (pos(t) - here).length() > 1e-9; + let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) }; + let mut step = ctx.comp.frame_duration().max(dt); + let (mut still, mut away) = (ctx.time, None); + for _ in 0..MOTION_SEARCH_STEPS { + let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) }; + if moved(t) { + away = Some(t); + break; + } + still = t; + if t == limit { + break; + } + step = step + step; + } + let mut away = away?; + for _ in 0..MOTION_SEARCH_STEPS { + if (still - away).abs() <= dt { + break; + } + let mid = away + (still - away).mul_ratio(1, 2); + if moved(mid) { + away = mid; + } else { + still = mid; + } + } + let d = if earlier { here - pos(away) } else { pos(away) - here }; + (d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize()) } /// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees. @@ -313,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 8e932819a..d203cfa71 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. -pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() @@ -218,7 +218,7 @@ struct PlaneInput { } /// Prepare the plane's geometry, motion samples and material. -fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; @@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: us } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer +/// can still be blurred. The blur holds a few images of this size at once whatever its level +/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than +/// blurring a large layer. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } @@ -449,8 +503,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; - if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + if let Some(collapsed) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, &collapsed, i, out); } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { @@ -483,20 +537,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, /// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they /// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack /// order. (Nested adjustment layers are skipped here.) -fn collapsed_items<'a>( - r: &Renderer<'a>, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - item: effectcraft_project::ItemId, - order: usize, - out: (u32, u32), -) -> Vec> { +fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.any_solo(); - let nested: Vec<&'a Layer> = nctx - .comp + let Some(nc) = r.project.comp(collapsed.item) else { return vec![] }; + let any_solo = nc.any_solo(); + let nested: Vec<&'a Layer> = nc .layers .iter() .rev() @@ -645,6 +692,13 @@ fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState], [lit[0] * a, lit[1] * a, lit[2] * a, a] } +/// Samples per side of the Shadow Diffusion kernel for a penumbra radius of `r_px` caster pixels: +/// odd, at least 3, and enough that neighbouring samples are at most 1.5 pixels apart, up to 15. +pub(crate) fn shadow_taps(r_px: f64) -> u32 { + let n = ((2.0 * r_px / 1.5).ceil() as u32).clamp(3, 15); + n | 1 +} + /// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded). fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { const FAR: f64 = 1.0e6; @@ -672,11 +726,17 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { if r * c.buf.scale < 0.5 { px = c.texel(hit.x, hit.y); } else { - for j in -1..=1 { - for i in -1..=1 { - let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66); + // Box filter over the penumbra square: n × n cell centres, dense enough that a wide + // diffusion blurs the caster instead of stamping copies of it (#2444). + let n = shadow_taps(r * c.buf.scale); + let step = 2.0 * r / n as f64; + let h = (n / 2) as i32; + let w = 1.0 / (n * n) as f32; + for j in -h..=h { + for i in -h..=h { + let q = c.texel(hit.x + f64::from(i) * step, hit.y + f64::from(j) * step); for k in 0..4 { - px[k] += q[k] / 9.0; + px[k] += q[k] * w; } } } @@ -850,7 +910,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh if nv[2] > 0.0 { nv = nv.map(|v| -v); } - let (sw, sh) = crate::source_size(r.project, it.layer); + let (sw, sh) = ctx.source_size(it.layer); let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) }) diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 9f1dbb344..026929623 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -344,10 +349,37 @@ fn shadow_darkness_and_accepts_off() { assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}"); } +#[test] +fn shadow_diffusion_is_a_soft_ramp_not_copies() { + // #2444: a wide Shadow Diffusion stamped 3 × 3 copies of the caster, so the penumbra stepped + // through three flat levels instead of ramping smoothly from lit to shadowed. + let (mut p, cid, comp) = setup(); + let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]); + no_specular(&mut recv); + let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]); + set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster + let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]); + set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0])); + set(&mut l, "lightOptions/castsShadows", Value::Bool(true)); + // 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius. + set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0)); + push(&mut p, cid, l); + push(&mut p, cid, caster); + push(&mut p, cid, recv); + let img = render(&p, cid); + let row: Vec = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect(); + // The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit. + assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}"); + let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count(); + assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}"); + let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max); + assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}"); +} + // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -423,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index 06f2599dc..f9c72e296 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource use effectcraft_time::{FrameRate, Tick}; use super::active_camera; +use super::camera::auto_orient_2d; use crate::EvalCtx; fn setup() -> (Project, ItemId, Comp) { @@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() { } } +/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by +/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the +/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees. +#[test] +fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() { + /// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like + /// a slider going from 0% to 100%). + struct LeftThenHold; + impl crate::ExprHost for LeftThenHold { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0); + Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0])) + } + } + for three_d in [false, true] { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + let pos = l.props.prop_mut("transform/position").unwrap(); + assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes"); + pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] { + let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) }; + let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0)); + // Moving left (-x): the layer's x axis points left before the motion starts and after it stops. + assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}"); + } + } +} + +/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end +/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on. +#[test] +fn orient_along_a_curved_expression_path_keeps_the_end_heading() { + /// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end. + struct Arc; + impl crate::ExprHost for Arc { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2; + Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0])) + } + } + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for frame in [31, 33, 37, 45, 59] { + let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) }; + let deg = auto_orient_2d(&ctx, &c.layers[0]); + assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}"); + } +} + #[test] fn layers_parented_to_a_camera_follow_its_rotation() { let (mut p, cid, comp) = setup(); diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index b64aaab37..3dd717aa8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> { const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +/// Animator property ids and their stable After Effects scripting names. Shared by lookup, +/// `.matchName`, and `addProperty` so the same property has one name in every direction. +pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ + ("anchor", "ADBE Text Anchor Point 3D"), + ("position", "ADBE Text Position 3D"), + ("scale", "ADBE Text Scale 3D"), + ("skew", "ADBE Text Skew"), + ("rotation", "ADBE Text Rotation"), + ("opacity", "ADBE Text Opacity"), + ("fillColor", "ADBE Text Fill Color"), + ("fillHue", "ADBE Text Fill Hue"), + ("fillSaturation", "ADBE Text Fill Saturation"), + ("fillBrightness", "ADBE Text Fill Brightness"), + ("fillOpacity", "ADBE Text Fill Opacity"), + ("strokeColor", "ADBE Text Stroke Color"), + ("strokeHue", "ADBE Text Stroke Hue"), + ("strokeSaturation", "ADBE Text Stroke Saturation"), + ("strokeBrightness", "ADBE Text Stroke Brightness"), + ("strokeOpacity", "ADBE Text Stroke Opacity"), + ("strokeWidth", "ADBE Text Stroke Width"), + ("tracking", "ADBE Text Tracking Amount"), + ("lineAnchor", "ADBE Text Line Anchor"), + ("lineSpacing", "ADBE Text Line Spacing"), + ("characterOffset", "ADBE Text Character Offset"), + ("characterValue", "ADBE Text Character Replace"), + ("blur", "ADBE Text Blur"), +]; + +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -487,6 +525,9 @@ pub fn node(chain: &[&Node]) -> String { "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 pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, 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), @@ -537,6 +578,30 @@ mod tests { assert_eq!(shape_kind("nope"), None); } + #[test] + fn text_animator_property_names_are_canonical_and_scoped() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_PROPERTIES { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids"); + } + } + + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 23356c064..258a6de2d 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R { /// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match /// name or a display name. fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ - ("ADBE Text Anchor Point 3D", "anchor"), - ("ADBE Text Position 3D", "position"), - ("ADBE Text Scale 3D", "scale"), - ("ADBE Text Skew", "skew"), - ("ADBE Text Rotation", "rotation"), - ("ADBE Text Opacity", "opacity"), - ("ADBE Text Fill Color", "fillColor"), - ("ADBE Text Fill Hue", "fillHue"), - ("ADBE Text Fill Saturation", "fillSaturation"), - ("ADBE Text Fill Brightness", "fillBrightness"), - ("ADBE Text Fill Opacity", "fillOpacity"), - ("ADBE Text Stroke Color", "strokeColor"), - ("ADBE Text Stroke Hue", "strokeHue"), - ("ADBE Text Stroke Saturation", "strokeSaturation"), - ("ADBE Text Stroke Brightness", "strokeBrightness"), - ("ADBE Text Stroke Opacity", "strokeOpacity"), - ("ADBE Text Stroke Width", "strokeWidth"), - ("ADBE Text Tracking Amount", "tracking"), - ("ADBE Text Line Anchor", "lineAnchor"), - ("ADBE Text Line Spacing", "lineSpacing"), - ("ADBE Text Character Offset", "characterOffset"), - ("ADBE Text Character Replace", "characterValue"), - ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + let table = matchnames::TEXT_ANIMATOR_PROPERTIES; + if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f012ce128..a33714314 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -734,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 4103f12c1..c024fcad5 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -394,19 +394,33 @@ mod tests { /// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls. /// In particular, Windows may accept that entire write with its default send buffering. + /// Writes must stay blocked for [`SETTLED`] before the buffers count as full: on macOS the + /// accepted stream does not keep the listener's 4 KiB receive buffer (it gets about 114 KiB), + /// so the first `WouldBlock` only means the 4 KiB send buffer is full, and the send buffer then + /// drains into the peer's larger receive buffer. fn backpressure(id: u32) { + const SETTLED: Duration = Duration::from_millis(200); let mut stream = super::imp::test_stream(id); socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); stream.set_nonblocking(true).unwrap(); let start = Instant::now(); let chunk = [b'x'; 4096]; let mut sent = 0; + let mut blocked_since = None; loop { assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure"); match stream.write(&chunk) { Ok(0) => panic!("peer closed while establishing backpressure"), - Ok(n) => sent += n, - Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Ok(n) => { + sent += n; + blocked_since = None; + } + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if blocked_since.get_or_insert_with(Instant::now).elapsed() >= SETTLED { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } Err(e) if e.kind() == ErrorKind::Interrupted => continue, Err(e) => panic!("establishing backpressure: {e}"), } diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index a2f674d0a..875885c11 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1719,6 +1719,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 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"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name, + added.matchName, props.property(added.matchName).propertyIndex] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); +} + +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test] diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index 009cc59c6..f075250b2 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -608,3 +608,37 @@ fn presets_saved_by_the_old_core_panel_carry_over() { let win = open_panel(&mut s); assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); } + +/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the +/// unused first-in and last-out sides; editing its speeds makes a Bezier curve. +#[test] +fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + // Start from Bezier keys so Linear has something to restore. + ok( + &mut s, + r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType; + for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#, + ); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}"); + } + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}"); + } + // Edited speeds make a custom Bezier curve even though Linear is still the selected preset. + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]); + let k = &prop(&s, l, "transform/opacity").keys[1]; + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); +} diff --git a/crates/script/src/tests/panels.rs b/crates/script/src/tests/panels.rs index c398a3a3f..3318d060f 100644 --- a/crates/script/src/tests/panels.rs +++ b/crates/script/src/tests/panels.rs @@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() { let mut s = session(&store); assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []})); } + +/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens +/// titled after its file and opens again at the next launch. +#[test] +fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap(); + let mut s = session(&store); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate")); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); + // A saved name that leaves the folder, or goes two folders deep, fails and is forgotten. + store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap(); + drop(s); + let mut s = session(&store); + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"])); + assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["../Top.jsx", "a/b/c.jsx"]); + assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some()); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); +} diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 228a58016..483d686ef 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index d2720bb0d..95c00a91a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1261,6 +1261,10 @@ fn xml_depth(text: &str) -> usize { max } +/// Nodes a document may expand to: a few kilobytes of nested internal entities +/// (``) otherwise expand to millions and exhaust memory. +const MAX_XML_NODES: u32 = 4_000_000; + /// Parse an SVG document. pub fn parse(bytes: &[u8]) -> Result { let text = String::from_utf8_lossy(bytes); @@ -1268,7 +1272,8 @@ pub fn parse(bytes: &[u8]) -> Result { if xml_depth(text) > MAX_XML_DEPTH { return Err(Error::Xml(format!("elements nested more than {MAX_XML_DEPTH} levels deep"))); } - let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + // DTDs stay allowed (Illustrator's SVGs declare entities), but expansion is bounded (#2129). + let opts = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: MAX_XML_NODES, ..Default::default() }; let xml = roxmltree::Document::parse_with_options(text, opts).map_err(|e| Error::Xml(e.to_string()))?; let root = xml.root_element(); if root.tag_name().name() != "svg" { diff --git a/crates/svg/src/pathdata.rs b/crates/svg/src/pathdata.rs index c8fa7f67f..7a2762db7 100644 --- a/crates/svg/src/pathdata.rs +++ b/crates/svg/src/pathdata.rs @@ -226,9 +226,18 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las let sweep = l.flag()?; let e = pt(l)?; let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep }; - match kurbo::Arc::from_svg_arc(&arc) { - Some(a) => a.to_cubic_beziers(0.1, |c1, c2, end| p.curve_to(c1, c2, end)), - None => p.line_to(e), + let mut last_end = None; + if let Some(a) = kurbo::Arc::from_svg_arc(&arc) { + // Scale tolerance with the radius so the segment count stays bounded. + let tol = 0.1_f64.max(a.radii.x.max(a.radii.y) * 1e-4); + a.to_cubic_beziers(tol, |c1, c2, end| { + p.curve_to(c1, c2, end); + last_end = Some(end); + }); + } + // Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point. + if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) { + p.line_to(e); } *cur = e; *last_cubic = None; @@ -261,6 +270,12 @@ mod tests { use super::*; use kurbo::PathEl; + #[test] + fn huge_arc_radius_stays_bounded() { + let p = parse_path_data("M0 0A1e36 1e36 0 1 1 1e30 0"); + assert!(p.elements().len() < 1000, "got {} elements", p.elements().len()); + } + #[test] fn commands() { let p = parse_path_data("M10,10 L20,10 h10 v10 H10 z m5 5 l1-1.5.5.5e1 C 0 0 1 1 2 2 s3 3 4 4 Q1 1 2 2 t3 3 A5 5 0 0 1 20 20 a5 5 0 1010 0"); @@ -294,6 +309,18 @@ mod tests { assert!(parse_path_data("M0 0 L10 0 z 9 9").elements().len() <= 4); } + #[test] + fn huge_radius_arc_still_reaches_end_point() { + for d in ["M0 0 A1e20 1e20 0 0 1 10 0 L10 10", "M0 0 A1e20 1e20 0 1 1 10 0 L10 10", "M0 0 a1e20 1e20 0 0 1 10 0 L10 10"] { + let p = parse_path_data(d); + let reaches = p.elements().iter().any(|e| match e { + PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => (q.x - 10.0).abs() < 1e-6 && q.y.abs() < 1e-6, + _ => false, + }); + assert!(reaches, "{d}: {:?}", p.elements()); + } + } + #[test] fn number_lists() { assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 31c95e62f..06dfbd333 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -220,6 +220,18 @@ fn transforms() { assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9); } +#[test] +fn nested_entities_are_refused_instead_of_expanding_without_bound() { + // #2129: each top-level `&b;` stays under roxmltree's per-reference cap but adds 12 500 + // nodes, so 40 KB of references expands to 5 million. + let dtd = format!("", "".repeat(50), "&a;".repeat(250)); + let bomb = format!("{}", "&b;".repeat(400)); + assert!(matches!(crate::parse(bomb.as_bytes()), Err(crate::Error::Xml(_)))); + // An ordinary entity, as Illustrator declares them, still parses. + let ok = "]>"; + assert!(crate::parse(ok.as_bytes()).is_ok()); +} + #[test] fn deep_nesting_errors_instead_of_overflowing_the_stack() { // 100 000 nested groups overflowed the XML parser's stack and aborted the process. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a973335f4..a31a17124 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -707,14 +707,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 079a001e0..9a6ba365a 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -1,5 +1,14 @@ use super::*; +/// Run `test` again in a child process with `variable` set. The fixture faces below go into the +/// process-global font database and its fallback caches, so only a process that runs one such test +/// on its own can use them. +#[cfg(not(target_arch = "wasm32"))] +fn run_in_child(variable: &str, test: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", test, "--nocapture"]).env(variable, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} + /// Concurrent language requests must neither choose another context's font nor reuse its /// cached family order. Synthetic fonts in a child process keep this independent of the host /// font installation and of other tests that exercise the process-global database/locale. @@ -8,12 +17,7 @@ use super::*; fn cjk_requests_keep_their_language_and_cache_entries() { const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; if std::env::var_os(CHILD).is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + run_in_child(CHILD, "fonts::tests::cjk_requests_keep_their_language_and_cache_entries"); return; } let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; @@ -71,6 +75,52 @@ fn cjk_requests_keep_their_language_and_cache_entries() { } } +/// #453: with a Chinese interface, a Han ideograph that both a Chinese and a Japanese face cover +/// must reach the installed Simplified Chinese family first, or Chinese text in a composition is +/// drawn with Japanese glyph forms. Fixture faces named after the macOS families keep this +/// independent of the host's fonts and of rendering. Before the fix the Simplified list named none +/// of the macOS faces a stock install has, so the walk below reached the Japanese face - the +/// regression this guards. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zh_hans_han_fallback_puts_the_chinese_family_first() { + const CHILD: &str = "EFFECTCRAFT_TEST_ZH_HANS_ORDER"; + if std::env::var_os(CHILD).is_none() { + run_in_child(CHILD, "fonts::tests::zh_hans_han_fallback_puts_the_chinese_family_first"); + return; + } + let mut ids = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + // The Japanese face is added first, so scan order cannot be what picks the Chinese one. + for family in ["Hiragino Sans", "Hiragino Sans GB"] { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + ids.push(push(&mut d, info_from(names, FaceData::Shared(data), "test"))); + } + } + let &[japanese, chinese] = ids.as_slice() else { panic!("two fixture faces expected: {ids:?}") }; + let base = resolve("Inter", "Regular").face; + assert!(face(japanese).has_char('页') && face(chinese).has_char('页')); + // The fallback list itself, before any coverage check: the Chinese family comes first for + // zh-hans and second for ja, whether or not either face can draw the probe. + let zh_hans: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Simplified).to_vec(); + assert_eq!(zh_hans, [chinese, japanese], "zh-hans tries the Chinese family first"); + let ja: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Japanese).to_vec(); + assert_eq!(ja, [japanese, chinese], "ja keeps the Japanese family first"); + // ...and the face a layout actually gets for the shared ideograph. + assert_eq!(face(fallback_for_locale('页', base, "zh-hans")).info.family, "Hiragino Sans GB"); + assert_eq!(face(fallback_for_locale('页', base, "ja")).info.family, "Hiragino Sans"); +} + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -93,24 +143,47 @@ fn bundled_families_and_resolution() { assert!(!b.synth_bold); let faces = all_faces(); assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled")); - // A real installed Bold Italic takes priority. Partial installed families can instead - // supply the nearest weight/slant, so neither synthesis flag is fixed by the OS. + // A real installed Bold Italic — the face's own style, or a named instance of a variable + // face — takes priority. Partial installed families can instead supply the nearest + // weight/slant, so neither synthesis flag is fixed by the OS. for family in ["Noto Serif", "Inter"] { let resolved = resolve(family, "Bold Italic"); let selected = face(resolved.face); assert!(selected.info.family.eq_ignore_ascii_case(family)); assert!(!resolved.missing); let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); - if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) { + let exact = candidates + .iter() + .find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) + .or_else(|| candidates.iter().find(|f| f.info.instances.iter().any(|i| i.style.eq_ignore_ascii_case("Bold Italic")))); + if let Some(exact) = exact { assert_eq!(resolved.face, exact.id, "real style wins for {family}"); + let expected_instance = if exact.info.style.eq_ignore_ascii_case("Bold Italic") { + None + } else { + exact.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case("Bold Italic")).and_then(|i| u16::try_from(i).ok()) + }; + assert_eq!(resolved.instance, expected_instance, "exact style instance for {family}"); assert!(!resolved.synth_bold && !resolved.synth_italic); } else { - // Italic mismatch dominates any supported font weight distance. - let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 }; - let nearest = candidates.iter().map(|f| distance(f)).min().unwrap(); - assert_eq!(distance(&selected), nearest, "closest available style for {family}"); - assert_eq!(resolved.synth_bold, selected.info.weight <= 500); - assert_eq!(resolved.synth_italic, !selected.info.italic); + // Italic mismatch dominates any supported font weight distance. Named instances + // resolve at their effective weight and slant, not the face's defaults. + let selected_instance = resolved.instance.and_then(|i| selected.info.instances.get(usize::from(i))); + let selected_weight = selected_instance.map_or(selected.info.weight, |i| i.weight(selected.info.weight)); + let selected_italic = selected_instance.map_or(selected.info.italic, |i| i.italic(selected.info.italic)); + let distance = |weight: u16, italic: bool| u32::from(weight.abs_diff(700)) + if italic { 0 } else { 1_000 }; + let nearest = candidates + .iter() + .flat_map(|f| { + std::iter::once((f.info.weight, f.info.italic)) + .chain(f.info.instances.iter().map(move |i| (i.weight(f.info.weight), i.italic(f.info.italic)))) + }) + .map(|(weight, italic)| distance(weight, italic)) + .min() + .unwrap(); + assert_eq!(distance(selected_weight, selected_italic), nearest, "closest available style for {family}"); + assert_eq!(resolved.synth_bold, selected_weight <= 500); + assert_eq!(resolved.synth_italic, !selected_italic); } } } @@ -164,6 +237,7 @@ fn variable_named_instances_resolve_and_list() { ("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")), ("Black", 900.0, Some("EffectCraftInstanceTest-Black")), ("Extra Bold", 800.0, None), + ("Bold Italic", 700.0, Some("EffectCraftInstanceTest-BoldItalic")), ]; assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1); let wght = |r: &Resolved| instance_variations(r); @@ -185,10 +259,15 @@ fn variable_named_instances_resolve_and_list() { assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]); assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]); assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]); + // A named instance keeps its own index, so layout applies its axis values. + let bold_italic = resolve(FAMILY, "Bold Italic"); + assert!(!bold_italic.missing && !bold_italic.synth_bold && !bold_italic.synth_italic); + assert_eq!((bold_italic.face, bold_italic.instance), (black.face, Some(4))); + assert_eq!(wght(&bold_italic), vec![("wght".to_string(), 700.0)]); // The font menu lists every instance, in weight order. let fams = families(); let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1; - assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]); + assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black", "Bold Italic"]); // Static fonts are untouched. assert_eq!(resolve("Inter", "Bold").instance, None); } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 179478517..4c0b5a436 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,34 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { + let h = em * px; + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +603,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -598,20 +629,25 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. /// Margins (left, right) of a paragraph's first or later lines. -fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) { +/// `rtl` is the resolved paragraph direction (an unset direction follows the text). +fn margins_for(para: &ParagraphStyle, rtl: bool, first: bool) -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; - if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } + if rtl { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } } /// The width a line may fill (`None` for point text). -fn avail_for(para: &ParagraphStyle, width: Option, first: bool) -> Option { - let (l, r) = margins_for(para, first); +fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option, first: bool) -> Option { + let (l, r) = margins_for(para, rtl, first); width.map(|w| (w - l - r).max(1.0)) } /// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`. -fn align_for(para: &ParagraphStyle, width: Option, last: bool) -> Align { - if (last || width.is_none()) && para.align == Align::Justify { +fn align_for(para: &ParagraphStyle, rtl: bool, width: Option, last: bool) -> Align { + if rtl && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -622,6 +658,12 @@ fn style_metrics(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size()) } +/// The baseline of a box's first line for style run `si`: the `d` height, else the font ascent. +fn style_first(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: usize) -> f32 { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) +} + /// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run). type CharInfo = (usize, char, char, FaceId, bool, Form, usize); @@ -719,7 +761,7 @@ impl<'a> Para<'a> { } fn line_avail(&self, line: usize) -> f32 { - avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX) + avail_for(self.para, self.base_rtl, self.width, line == 0).unwrap_or(f32::MAX) } /// The byte ranges of the lines, for the wrap width. @@ -736,8 +778,11 @@ impl<'a> Para<'a> { ranges } - /// Minimises the summed squared slack of every line but the last. + /// Minimises the summed squared slack of every line but the last; with Justify All the last + /// line is stretched too, so its slack counts (After Effects then balances the lines: 4 / 4 / 4 + /// words where the other modes break 6 / 6 / 1). fn optimal_breaks(&self, opps: &[usize]) -> Vec> { + let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -757,7 +802,7 @@ impl<'a> Para<'a> { let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -802,8 +847,8 @@ impl<'a> Para<'a> { /// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one. fn place_line(&self, li: usize, r: Range, last: bool) -> ParaLine { - let align = align_for(self.para, self.width, last); - let avail = avail_for(self.para, self.width, li == 0); + let align = align_for(self.para, self.base_rtl, self.width, last); + let avail = avail_for(self.para, self.base_rtl, self.width, li == 0); let (ce, tw) = self.trailing_ws(&r); let hang = self.hang_of(&r, ce); let content = self.range_w(r.start..ce); @@ -819,7 +864,7 @@ impl<'a> Para<'a> { } let placed = self.place_glyphs(&r, space_extra, ce); let carets = self.carets(&r, &placed.extents, placed.pen); - let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs); + let (asc, desc, adv, first) = self.line_metrics(&r, &placed.glyphs); let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content }; ParaLine { range: self.base + r.start..self.base + r.end, @@ -830,10 +875,11 @@ impl<'a> Para<'a> { hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, - margins: margins_for(self.para, li == 0), + margins: margins_for(self.para, self.base_rtl, li == 0), underline: placed.underline, } } @@ -938,8 +984,8 @@ impl<'a> Para<'a> { } /// Ascent, descent and advance of the line: the tallest face and the largest leading on it. - fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32) { - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32, f32) { + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let char_start = self.chars.partition_point(|c| c.0 < r.start); let char_end = self.chars.partition_point(|c| c.0 < r.end); let mut seen: Vec = self.chars[char_start..char_end].iter().map(|c| c.6).collect(); @@ -954,6 +1000,7 @@ impl<'a> Para<'a> { asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(self.runs[si].1.line_advance()); + first = first.max(style_first(self.primaries, self.runs, si)); } for g in glyphs { if g.face != self.primaries[g.run].face { @@ -962,7 +1009,7 @@ impl<'a> Para<'a> { desc = desc.max(m.descent); } } - (asc, desc, adv) + (asc, desc, adv, first) } } @@ -1037,6 +1084,8 @@ fn paragraph( if text.is_empty() { let si = run_at(runs, base); let pm = style_metrics(primaries, runs, si); + // No text to resolve a direction from: the explicit one. + let rtl = para.rtl == Some(true); return vec![ParaLine { range: base..base, glyphs: vec![], @@ -1047,9 +1096,10 @@ fn paragraph( ascent: pm.ascent, descent: pm.descent, advance: runs[si].1.line_advance(), - align: align_for(para, width, true), - avail: avail_for(para, width, true), - margins: margins_for(para, true), + first: style_first(primaries, runs, si), + align: align_for(para, rtl, width, true), + avail: avail_for(para, rtl, width, true), + margins: margins_for(para, rtl, true), underline: None, }]; } @@ -1120,7 +1170,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 9079a1225..5792c1c16 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -357,3 +357,140 @@ fn named_instance_sets_the_base_variations() { let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default()); assert!(inter.glyphs.iter().all(|g| g.variations == 0)); } + +/// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole +/// thousandths of an em) below the box top, at the nominal size also for superscript, whose +/// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the +/// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the +/// superscript glyphs 15.8 px low. +#[test] +fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } +} + +/// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the +/// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. +#[test] +fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } +} + +/// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that +/// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the +/// stretched last line is not a lone word; word positions then match AE to 1 px. +#[test] +fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); +} + +/// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified +/// code (justify last left / centre / right, justify all) sits flush right at its anchor, as +/// right-aligned text does. +#[test] +fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } +} + +/// The same holds when the direction is unset and the text itself resolves right to left. +#[test] +fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); +} + +/// Indents follow the resolved direction too: detected right-to-left text puts its start +/// indent on the right, as an explicit right-to-left paragraph does. +#[test] +fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |rtl: Option| { + let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (explicit, detected) = (at(Some(true)), at(None)); + assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}"); +} + +/// Only the first line's own runs decide whether box text starts at the font ascent: a +/// vertical run further down leaves a horizontal first line on its `d`-height baseline. +#[test] +fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7ecc06..8cc8e3264 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,15 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +361,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 7a64a82bb..9bc625d10 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -198,7 +198,9 @@ impl FrameRate { return r; } } - FrameRate::new((fps * 1000.0).round() as i64, 1000) + let num = (fps * 1000.0).round() as i64; + // A tiny positive rate must not round to the invalid 0/1 rate. + FrameRate::new(if fps > 0.0 { num.max(1) } else { num }, 1000) } pub fn as_f64(self) -> f64 { @@ -387,7 +389,11 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp let rem = Tick(abs.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); format!("{sign}{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) } - TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + TimeDisplay::Seconds => { + let s = t.seconds(); + // A value that rounds to zero must not print a sign ("-0.000"). + format!("{:.3}", if (s * 1000.0).round() == 0.0 { 0.0 } else { s }) + } } } @@ -533,6 +539,21 @@ pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_ mod tests { use super::*; + #[test] + fn from_f64_tiny_positive_fps_is_nonzero() { + let r = FrameRate::from_f64(0.0001); + assert!(r.num > 0 && r.den > 0, "got {r:?}"); + } + + #[test] + fn seconds_display_has_no_negative_zero() { + for t in [Tick(-1), Tick(0), Tick(-100)] { + assert_eq!(format_time(t, FrameRate::FPS_30, false, TimeDisplay::Seconds, 48000), "0.000"); + } + let one = Tick(-TICKS_PER_SECOND); + assert_eq!(format_time(one, FrameRate::FPS_30, false, TimeDisplay::Seconds, 48000), "-1.000"); + } + #[test] fn frame_rate_constructors_accept_extreme_numbers() { assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index e03341d14..a4412dbce 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -11,7 +11,7 @@ use rayon::prelude::*; use super::geometry::Pose; -use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; +use super::linalg::{M3, V3, add, cholesky_solve, conjugate_gradient, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; use super::{Distortion, SolveError}; /// A camera in the adjustment. @@ -216,8 +216,52 @@ fn layout(ncam: usize, o: &BaOpts) -> Layout { Layout { start, fidx, shared, dist, n, rot_only: o.fix_centers } } +// Six pose parameters, one shared OR per-camera focal parameter, two distortion parameters. +const MAX_CAMERA_JACOBIAN_ENTRIES: usize = 6 + 1 + 2; +type CameraJacobianEntry = (usize, [f64; 2]); + +/// Keeps the current parameter set on the stack. Extra parameters spill rather than panic +/// if a future extension grows beyond the inline capacity. +struct CameraJacobian { + entries: [CameraJacobianEntry; MAX_CAMERA_JACOBIAN_ENTRIES], + len: usize, + overflow: Vec, +} + +impl CameraJacobian { + fn new() -> Self { + Self { entries: [(0, [0.0; 2]); MAX_CAMERA_JACOBIAN_ENTRIES], len: 0, overflow: Vec::new() } + } + + fn clear(&mut self) { + self.len = 0; + self.overflow.clear(); + } + + fn push(&mut self, entry: CameraJacobianEntry) { + if self.overflow.is_empty() { + if let Some(slot) = self.entries.get_mut(self.len) { + *slot = entry; + self.len += 1; + return; + } + self.overflow.extend_from_slice(&self.entries); + } + self.overflow.push(entry); + } + + fn as_slice(&self) -> &[CameraJacobianEntry] { + if self.overflow.is_empty() { + // len only grows after a successful get_mut, so it cannot exceed capacity. + self.entries.get(..self.len).unwrap_or_default() + } else { + &self.overflow + } + } +} + /// Camera-side Jacobian entries of one observation: (parameter index, d r / d param). -fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; 2], out: &mut Vec<(usize, [f64; 2])>) { +fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; 2], out: &mut CameraJacobian) { out.clear(); let s = l.start[ci]; if s != usize::MAX { @@ -243,6 +287,101 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +struct PtSys { + v: M3, + bp: V3, + w: Vec<(usize, V3)>, +} + +struct PreconditionerBlock { + start: usize, + dim: usize, + inverse: Vec, +} + +fn camera_preconditioner(u: &[f64], l: &Layout) -> Option> { + let mut ranges = Vec::new(); + let pose_dim = if l.rot_only { 3 } else { 6 }; + for (&pose, &focal) in l.start.iter().zip(&l.fidx) { + if pose != usize::MAX { + ranges.push((pose, pose_dim + usize::from(focal != usize::MAX))); + } else if focal != usize::MAX { + ranges.push((focal, 1)); + } + } + if let Some(start) = l.shared.or(l.dist) { + ranges.push((start, l.n - start)); + } + ranges + .into_iter() + .map(|(start, dim)| { + let mut matrix = Vec::with_capacity(dim * dim); + for i in 0..dim { + for j in 0..dim { + matrix.push(*u.get((start + i) * l.n + start + j)?); + } + } + let mut inverse = vec![0.0; dim * dim]; + for col in 0..dim { + let mut a = matrix.clone(); + let mut b = vec![0.0; dim]; + *b.get_mut(col)? = 1.0; + if !cholesky_solve(&mut a, dim, &mut b) { + return None; + } + for (row, value) in b.into_iter().enumerate() { + inverse[row * dim + col] = value; + } + } + Some(PreconditionerBlock { start, dim, inverse }) + }) + .collect() +} + +/// Apply the point-eliminated system without materialising W V^-1 W^T. +fn iterative_schur(u: &[f64], rhs: &[f64], systems: &[PtSys], vinv: &[Option], l: &Layout) -> Option> { + if l.n == 0 || l.n.checked_mul(l.n) != Some(u.len()) || rhs.len() != l.n || systems.len() != vinv.len() { + return None; + } + let blocks = camera_preconditioner(u, l)?; + // U contains camera blocks, global parameters and the focal prior. Store its nonzeros + // once; all subsequent products are linear in these and the point-camera connections. + let rows: Vec> = + u.chunks_exact(l.n).map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0.0).map(|(i, v)| (i, *v)).collect()).collect(); + let mut reduced_rhs = rhs.to_vec(); + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let vb = mv(vi, ps.bp); + for (i, w) in &ps.w { + *reduced_rhs.get_mut(*i)? -= dot(*w, vb); + } + } + conjugate_gradient( + &reduced_rhs, + |x, y| { + for (i, row) in rows.iter().enumerate() { + y[i] = row.iter().map(|(j, v)| v * x[*j]).sum(); + } + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let q = ps.w.iter().fold([0.0; 3], |q, (i, w)| add(q, scale(*w, x[*i]))); + let q = mv(vi, q); + for (i, w) in &ps.w { + y[*i] -= dot(*w, q); + } + } + }, + |r, z| { + z.fill(0.0); + for block in &blocks { + for i in 0..block.dim { + z[block.start + i] = (0..block.dim).map(|j| block.inverse[i * block.dim + j] * r[block.start + j]).sum(); + } + } + }, + ) +} + /// Smooth per-camera log focal lengths in the normal equations. fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { let n = l.n; @@ -266,10 +405,56 @@ fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc } } +/// Eliminate the points into the camera system explicitly (`S -= W V^-1 W^T`, `rhs -= W V^-1 bp`). +fn reduce_points(systems: &[PtSys], vinv: &[Option], n: usize, s: &mut [f64], rhs: &mut [f64]) { + let npts = systems.len(); + let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { + for j in range { + let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; + let vb = mv(vi, ps.bp); + let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); + for (a, (ia, wa)) in ps.w.iter().enumerate() { + rhs[*ia] -= dot(*wa, vb); + for (ib, wb) in &ps.w[a..] { + let d = dot(wv[a], *wb); + s[ia * n + ib] -= d; + if ia != ib { + s[ib * n + ia] -= d; + } + } + } + } + }; + let chunks = rayon::current_num_threads().clamp(1, 8); + if n <= 1200 && npts >= 64 && chunks > 1 { + let per = npts.div_ceil(chunks); + let parts: Vec<(Vec, Vec)> = (0..chunks) + .into_par_iter() + .map(|t| { + let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); + reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); + (ds, dr) + }) + .collect(); + for (ds, dr) in parts { + s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); + for (x, y) in rhs.iter_mut().zip(dr) { + *x += y; + } + } + } else { + reduce(0..npts, s, rhs); + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. /// Invalid inputs or workspaces exceeding 256 MiB return an error before mutation. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> Result { validate(cams, pts, obs, o)?; + adjust_impl(cams, pts, obs, o, true) +} + +fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, allow_iterative: bool) -> Result { let l = layout(cams.len(), o); let matrix_len = matrix_len(l.n, pts.len(), obs.len())?; let npts = pts.len(); @@ -297,14 +482,9 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> iters += 1; // ---- Normal equations (Gauss–Newton with IRLS weights). // Per point: V (3×3), bp (3), W entries (cam-side index → 3). - struct PtSys { - v: [[f64; 3]; 3], - bp: V3, - w: Vec<(usize, V3)>, - } let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; - let mut jc: Vec<(usize, [f64; 2])> = Vec::with_capacity(8); + let mut jc = CameraJacobian::new(); for &oi in &by_pt[j] { let ob = &obs[oi as usize]; let ci = ob.cam as usize; @@ -329,6 +509,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> jc.push((g, [dk[0][0], dk[1][0]])); jc.push((g + 1, [dk[0][1], dk[1][1]])); } + let jc = jc.as_slice(); // Point Jacobian: A R. let jx = if o.fix_points { [[0.0; 3]; 2] @@ -337,9 +518,9 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let r1 = mtv(&c.pose.r, a[1]); [r0, r1] }; - for (ia, ja) in &jc { + for (ia, ja) in jc.iter() { bc[*ia] -= w * (ja[0] * r[0] + ja[1] * r[1]); - for (ib, jb) in &jc { + for (ib, jb) in jc.iter() { s_acc[ia * n + ib] += w * (ja[0] * jb[0] + ja[1] * jb[1]); } } @@ -350,7 +531,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> ps.v[a1][b1] += w * (jx[0][a1] * jx[0][b1] + jx[1][a1] * jx[1][b1]); } } - for (ia, ja) in &jc { + for (ia, ja) in jc.iter() { let wv = [w * (ja[0] * jx[0][0] + ja[1] * jx[1][0]), w * (ja[0] * jx[0][1] + ja[1] * jx[1][1]), w * (ja[0] * jx[0][2] + ja[1] * jx[1][2])]; // Each observation of a point is on another camera: only the shared focal @@ -400,6 +581,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); + let schur_pairs = systems.iter().fold(0usize, |sum, ps| sum.saturating_add(ps.w.len().saturating_mul(ps.w.len().saturating_add(1)) / 2)); + // Reserve the iterative solve for expensive long-track reductions; small systems + // retain the direct path and its rounding. Failed solves use the direct path too. + let mut use_iterative = allow_iterative && !o.fix_points && n >= 120 && schur_pairs > 4_000_000; // ---- Try steps with increasing damping. let mut accepted = false; for _try in 0..12 { @@ -427,50 +612,26 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> }) .collect() }; - if !o.fix_points { - let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { - for j in range { - let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; - let vb = mv(vi, ps.bp); - let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); - for (a, (ia, wa)) in ps.w.iter().enumerate() { - rhs[*ia] -= dot(*wa, vb); - for (ib, wb) in &ps.w[a..] { - let d = dot(wv[a], *wb); - s[ia * n + ib] -= d; - if ia != ib { - s[ib * n + ia] -= d; - } - } - } - } - }; - let chunks = rayon::current_num_threads().clamp(1, 8); - if n <= 1200 && npts >= 64 && chunks > 1 { - let per = npts.div_ceil(chunks); - let parts: Vec<(Vec, Vec)> = (0..chunks) - .into_par_iter() - .map(|t| { - let (mut ds, mut dr) = (vec![0.0; matrix_len], vec![0.0; n]); - reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); - (ds, dr) - }) - .collect(); - for (ds, dr) in parts { - s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); - for (x, y) in rhs.iter_mut().zip(dr) { - *x += y; - } - } - } else { - reduce(0..npts, &mut s, &mut rhs); + let iterative_step = if use_iterative { iterative_schur(&s, &rhs, &systems, &vinv, &l) } else { None }; + if dbg && use_iterative { + eprintln!("ba iterative n={n} pairs={schur_pairs} solved={}", iterative_step.is_some()); + } + if use_iterative && iterative_step.is_none() { + use_iterative = false; + } + let dc = if let Some(dc) = iterative_step { + dc + } else { + if !o.fix_points { + reduce_points(&systems, &vinv, n, &mut s, &mut rhs); } - } - let mut dc = rhs; - if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { - lambda *= 10.0; - continue; - } + let mut dc = rhs; + if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { + lambda *= 10.0; + continue; + } + dc + }; // Apply. let mut nc: Vec = cams.to_vec(); for (i, c) in nc.iter_mut().enumerate() { @@ -548,6 +709,112 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> mod tests { use super::*; use crate::camtrack::geometry::Rng; + use crate::camtrack::linalg::{I3, log_rot, mt, norm}; + + #[test] + fn long_track_adjustment_matches_direct_reprojections_and_outliers() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (focal, fix_centers, distortion) in + [(FocalMode::Fixed, false, false), (FocalMode::Shared, false, true), (FocalMode::PerCamera(30.0), false, true), (FocalMode::Shared, true, true)] + { + let mut rng = Rng(71); + let mut noise = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5; + let truth: Vec = (0..320).map(|_| [noise() * 8.0, noise() * 5.0, 10.0 + noise() * 5.0]).collect(); + let true_cams: Vec<_> = (0..60) + .map(|i| BaCam { + pose: Pose { + r: rodrigues([0.001 * i as f64, -0.003 * i as f64, 0.0]), + c: if fix_centers { [0.0; 3] } else { [0.03 * i as f64, 0.01 * (i as f64).sin(), 0.0] }, + }, + f: if matches!(focal, FocalMode::PerCamera(_)) { 900.0 * (1.0 + 0.05 * (i as f64 * 0.07).sin()) } else { 900.0 }, + dist: if distortion { Distortion { k1: 0.015, k2: -0.005, radius: 1100.0 } } else { Distortion::NONE }, + }) + .collect(); + let mut obs = Vec::new(); + for (ci, c) in true_cams.iter().enumerate() { + for (pi, p) in truth.iter().enumerate() { + let mut uv = residual(c, *p, [0.0; 2]).unwrap(); + uv[0] += noise() * 0.05; + uv[1] += noise() * 0.05; + if (ci * truth.len() + pi).is_multiple_of(37) { + uv[0] += 25.0; + uv[1] -= 30.0; + } + obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv }); + } + } + let mut cams = true_cams.clone(); + for c in cams.iter_mut().skip(2) { + c.pose.r = mm(&rodrigues([0.001, -0.001, 0.0005]), &c.pose.r); + if !fix_centers { + c.pose.c = add(c.pose.c, [0.002, -0.003, 0.001]); + } + } + let mut pts: Vec<_> = truth.iter().map(|p| add(*p, [0.01, -0.01, 0.005])).collect(); + let (mut direct_cams, mut direct_pts) = (cams.clone(), pts.clone()); + let opts = BaOpts { focal, fix_centers, distortion, fixed: (0..60).map(|i| i < 2).collect(), max_iter: 10, ..Default::default() }; + let report = adjust(&mut cams, &mut pts, &obs, &opts).unwrap(); + let direct = adjust_impl(&mut direct_cams, &mut direct_pts, &obs, &opts, false).unwrap(); + assert!((report.cost - direct.cost).abs() < 1e-5 * direct.cost.max(1.0)); + for ob in &obs { + let a = residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv).unwrap(); + let b = residual(&direct_cams[ob.cam as usize], direct_pts[ob.pt as usize], ob.uv).unwrap(); + assert!((a[0] - b[0]).hypot(a[1] - b[1]) < 0.001); + assert_eq!(a[0].hypot(a[1]) <= 4.0, b[0].hypot(b[1]) <= 4.0); + } + for (a, b) in cams.iter().zip(direct_cams) { + assert!(norm(sub(a.pose.c, b.pose.c)) < 0.001); + assert!(norm(log_rot(&mm(&a.pose.r, &mt(&b.pose.r)))) < 1e-4); + assert!((a.f - b.f).abs() < 1e-4 * b.f); + assert!((a.dist.k1 - b.dist.k1).abs() < 1e-4 && (a.dist.k2 - b.dist.k2).abs() < 1e-4); + } + } + }); + } + + #[test] + fn matrix_free_schur_matches_explicit_system_and_rejects_bad_preconditioner() { + let l = layout(20, &BaOpts::default()); + let n = l.n; + let mut u = vec![0.0; n * n]; + for i in 0..n { + u[i * n + i] = 10.0; + } + let systems: Vec<_> = (0..80) + .map(|j| PtSys { + v: I3, + bp: [0.01, -0.02, 0.03], + w: (0..n).map(|i| (i, [((j * 13 + i * 7) % 19) as f64 * 0.001 - 0.009, (i % 7) as f64 * 0.001, (j % 11) as f64 * 0.001])).collect(), + }) + .collect(); + let vinv = vec![Some(I3); systems.len()]; + let rhs: Vec<_> = (0..n).map(|i| (i % 7) as f64 - 3.0).collect(); + let x = iterative_schur(&u, &rhs, &systems, &vinv, &l).unwrap(); + let mut explicit = u.clone(); + let mut reduced_rhs = rhs.clone(); + for ps in &systems { + for (i, a) in &ps.w { + reduced_rhs[*i] -= dot(*a, ps.bp); + for (j, b) in &ps.w { + explicit[i * n + j] -= dot(*a, *b); + } + } + } + let mut direct = reduced_rhs.clone(); + let mut factor = explicit.clone(); + assert!(cholesky_solve(&mut factor, n, &mut direct)); + for (a, b) in x.iter().zip(direct) { + assert!((a - b).abs() < 1e-9); + } + let mut error2 = 0.0; + for (i, row) in explicit.chunks_exact(n).enumerate() { + let ax: f64 = row.iter().zip(&x).map(|(a, x)| a * x).sum(); + error2 += (ax - reduced_rhs[i]).powi(2); + } + assert!(error2 < 1e-18 * reduced_rhs.iter().map(|v| v * v).sum::()); + u[0] = -1.0; + assert!(iterative_schur(&u, &rhs, &systems, &vinv, &l).is_none()); + } fn test_camera() -> BaCam { BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE } @@ -618,6 +885,26 @@ mod tests { assert!(matrix_len(955, 900, 160 * 900).is_ok()); } + #[test] + fn camera_jacobian_keeps_extra_parameters_and_resets() { + let mut jac = CameraJacobian::new(); + let count = MAX_CAMERA_JACOBIAN_ENTRIES + 3; + for i in 0..count { + jac.push((i, [i as f64, -(i as f64)])); + } + assert_eq!(jac.as_slice().iter().count(), count); + for (i, entry) in jac.as_slice().iter().enumerate() { + assert_eq!(*entry, (i, [i as f64, -(i as f64)])); + } + jac.clear(); + assert_eq!(jac.as_slice().iter().count(), 0); + for i in 0..MAX_CAMERA_JACOBIAN_ENTRIES { + jac.push((i, [1.0, 2.0])); + } + assert_eq!(jac.as_slice().iter().count(), MAX_CAMERA_JACOBIAN_ENTRIES); + assert!(jac.overflow.is_empty()); + } + #[test] fn bundle_adjustment_converges_from_noise() { let mut rng = Rng(99); diff --git a/crates/track/src/camtrack/features.rs b/crates/track/src/camtrack/features.rs index 1775a304f..246febda4 100644 --- a/crates/track/src/camtrack/features.rs +++ b/crates/track/src/camtrack/features.rs @@ -61,15 +61,24 @@ impl TrackAnalyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { - let (max_side, want) = self.params(); + let (max_side, _) = self.params(); let factor = analysis_factor(frame.img.width, frame.img.height, max_side); - self.factor = factor as f64; let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 4); + let prev = self.prev.take(); + self.push_pyramid(prev.as_ref(), &pyr); + self.prev = Some(pyr); + } + + /// Track using caller-owned consecutive pyramids. The caller retains the full pyramid + /// for the next frame; standalone camera analysis uses the same path through `push`. + pub(crate) fn push_pyramid(&mut self, prev: Option<&GrayPyramid>, pyr: &GrayPyramid) { + let (_, want) = self.params(); + self.factor = pyr.factor; let k = self.frame; - match &self.prev { + match prev { Some(prev) if prev.width() == pyr.width() && prev.height() == pyr.height() && prev.offset == pyr.offset => { let pts: Vec<[f64; 2]> = self.active.iter().map(|a| a.1).collect(); - let q = track(prev, &pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); + let q = track(prev, pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); let mut next = Vec::with_capacity(self.active.len()); for ((ti, _), q) in self.active.iter().zip(q) { if let Some(q) = q { @@ -101,14 +110,13 @@ impl TrackAnalyzer { !occ.get(cy * gw + cx).copied().unwrap_or(true) }; let opts = CornerOpts { max_features: want - self.active.len().min(want), min_distance: md, quality: 0.001, window: 2, border: 8 }; - for p in good_features(&pyr, &opts, Some(&free)) { + for p in good_features(pyr, &opts, Some(&free)) { let l = pyr.to_layer(p); let id = self.tracks.len() as u32; self.tracks.push(Track2D { id, start: k, pts: vec![[l[0] as f32, l[1] as f32]] }); self.active.push((id as usize, p)); } } - self.prev = Some(pyr); self.frame += 1; } diff --git a/crates/track/src/camtrack/geometry.rs b/crates/track/src/camtrack/geometry.rs index cc633a9c6..3ce32a397 100644 --- a/crates/track/src/camtrack/geometry.rs +++ b/crates/track/src/camtrack/geometry.rs @@ -207,8 +207,8 @@ pub fn triangulate(views: &[(Pose, [f64; 2])]) -> Option { for (p, x) in views { let t = p.t(); let rows = [[p.r[0][0], p.r[0][1], p.r[0][2], t[0]], [p.r[1][0], p.r[1][1], p.r[1][2], t[1]], [p.r[2][0], p.r[2][1], p.r[2][2], t[2]]]; - let a0: Vec = (0..4).map(|j| x[0] * rows[2][j] - rows[0][j]).collect(); - let a1: Vec = (0..4).map(|j| x[1] * rows[2][j] - rows[1][j]).collect(); + let a0 = [0, 1, 2, 3].map(|j| x[0] * rows[2][j] - rows[0][j]); + let a1 = [0, 1, 2, 3].map(|j| x[1] * rows[2][j] - rows[1][j]); for a in [a0, a1] { let nrm = a.iter().map(|v| v * v).sum::().sqrt().max(1e-300); for i in 0..4 { diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 2efc14556..71e88e109 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -145,6 +145,33 @@ pub fn slerp(a: &M3, b: &M3, t: f64) -> M3 { /// `max(m, n) × n` (rows beyond `m` belong to zero padding), `s` is sorted descending and `V` /// is `n × n` (columns are the right singular vectors). pub fn svd(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { + let rows = m.max(n); + let (mut u, mut s, v) = jacobi_factors(a, m, n); + for (j, sj) in s.iter().enumerate() { + if *sj > 1e-300 { + for r in 0..rows { + u[r * n + j] /= sj; + } + } + } + // Sort descending. + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| s[b].total_cmp(&s[a])); + let (mut u2, mut v2) = (vec![0.0; rows * n], vec![0.0; n * n]); + for (k, &j) in order.iter().enumerate() { + for r in 0..rows { + u2[r * n + k] = u[r * n + j]; + } + for r in 0..n { + v2[r * n + k] = v[r * n + j]; + } + } + s = order.iter().map(|&j| s[j]).collect(); + (u2, s, v2) +} + +// Unnormalised and unsorted factors; full SVD and null-space queries share every sweep. +fn jacobi_factors(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { let rows = m.max(n); let mut u = vec![0.0; rows * n]; u[..m * n].copy_from_slice(&a[..m * n]); @@ -192,28 +219,8 @@ pub fn svd(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { break; } } - let mut s: Vec = (0..n).map(|j| (0..rows).map(|r| u[r * n + j] * u[r * n + j]).sum::().sqrt()).collect(); - for (j, sj) in s.iter().enumerate() { - if *sj > 1e-300 { - for r in 0..rows { - u[r * n + j] /= sj; - } - } - } - // Sort descending. - let mut order: Vec = (0..n).collect(); - order.sort_by(|&a, &b| s[b].total_cmp(&s[a])); - let (mut u2, mut v2) = (vec![0.0; rows * n], vec![0.0; n * n]); - for (k, &j) in order.iter().enumerate() { - for r in 0..rows { - u2[r * n + k] = u[r * n + j]; - } - for r in 0..n { - v2[r * n + k] = v[r * n + j]; - } - } - s = order.iter().map(|&j| s[j]).collect(); - (u2, s, v2) + let s: Vec = (0..n).map(|j| (0..rows).map(|r| u[r * n + j] * u[r * n + j]).sum::().sqrt()).collect(); + (u, s, v) } /// SVD of a 3 × 3 matrix: (U, singular values descending, V) with `m = U diag(s) Vᵀ`. @@ -235,8 +242,10 @@ pub fn svd3(m: &M3) -> (M3, V3, M3) { /// The unit vector `x` minimising `|A x|` (`m × n` row-major): the right singular vector of /// the smallest singular value. pub fn null_vector(a: &[f64], m: usize, n: usize) -> Vec { - let (_, _, v) = svd(a, m, n); - (0..n).map(|r| v[r * n + n - 1]).collect() + let (_, s, v) = jacobi_factors(a, m, n); + // The full SVD uses a stable descending sort: the last of equal minima wins. + let smallest = (0..n).min_by(|&i, &j| s[i].total_cmp(&s[j]).then_with(|| j.cmp(&i))).unwrap_or(0); + (0..n).map(|r| v[r * n + smallest]).collect() } /// Eigen-decomposition of a symmetric `n × n` matrix (cyclic Jacobi): eigenvalues ascending @@ -317,9 +326,29 @@ fn dot_n(a: &[f64], b: &[f64]) -> f64 { /// Solve `A x = b` for a symmetric positive definite `A` (`n × n`, row-major) in place by /// Cholesky factorisation. Returns false when `A` is not positive definite. pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { + if n.checked_mul(n) != Some(a.len()) || b.len() != n { + return false; + } + let mut band = n.saturating_sub(1); + if b.iter().all(|v| v.is_finite()) { + band = 0; + 'bandwidth: for i in (0..n).rev() { + for j in 0..i.saturating_sub(band) { + if a[i * n + j] != 0.0 { + band = i - j; + // Wide systems (including shared global parameters) use the dense path. + if band >= n / 2 { + band = n.saturating_sub(1); + break 'bandwidth; + } + break; + } + } + } + } for j in 0..n { let mut d = a[j * n + j]; - for k in 0..j { + for k in j.saturating_sub(band)..j { d -= a[j * n + k] * a[j * n + k]; } if d <= 0.0 || !d.is_finite() { @@ -330,14 +359,24 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { let (head, tail) = a.split_at_mut((j + 1) * n); let rowj = &head[j * n..j * n + j]; // Rows below j (sequential: per-column parallel dispatch costs more than it saves). - for row in tail.chunks_mut(n) { - row[j] = (row[j] - dot_n(&row[..j], rowj)) / d; + for (off, row) in tail.chunks_mut(n).take(band).enumerate() { + // Removing full groups of four zero products preserves dot_n's lane order. + let start = (j + 1 + off).saturating_sub(band) / 4 * 4; + row[j] = (row[j] - dot_n(&row[start..j], &rowj[start..])) / d; } } // Forward: L y = b. for i in 0..n { let mut s = b[i]; - for k in 0..i { + let start = i.saturating_sub(band); + // Subtracting signed-zero products can change -0.0 to +0.0. Preserve those + // operations when the accumulator is zero; nonzero values are unaffected. + if s == 0.0 { + for k in 0..start { + s -= a[i * n + k] * b[k]; + } + } + for k in start..i { s -= a[i * n + k] * b[k]; } b[i] = s / a[i * n + i]; @@ -345,14 +384,74 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { // Back: Lᵀ x = y. for i in (0..n).rev() { let mut s = b[i]; - for k in i + 1..n { + let end = i.saturating_add(band).saturating_add(1).min(n); + for k in i + 1..end { s -= a[k * n + i] * b[k]; } + if s == 0.0 { + for k in end..n { + s -= a[k * n + i] * b[k]; + } + } b[i] = s / a[i * n + i]; } b.iter().all(|v| v.is_finite()) } +/// Preconditioned conjugate gradients with an independently recomputed residual check. +/// Failure to converge (or non-finite/non-positive curvature) requests the direct fallback. +pub(crate) fn conjugate_gradient(rhs: &[f64], apply: impl Fn(&[f64], &mut [f64]), precondition: impl Fn(&[f64], &mut [f64])) -> Option> { + let n = rhs.len(); + let norm2 = dot_n(rhs, rhs); + if !norm2.is_finite() { + return None; + } + if rhs.iter().all(|v| *v == 0.0) { + return Some(vec![0.0; n]); + } + let tolerance2 = norm2 * 1e-22; + if tolerance2 == 0.0 { + return None; + } + let mut x = vec![0.0; n]; + let mut r = rhs.to_vec(); + let mut z = vec![0.0; n]; + let mut ap = vec![0.0; n]; + precondition(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot_n(&r, &z); + for _ in 0..n.saturating_mul(2).min(512) { + if !rz.is_finite() || rz <= 0.0 { + return None; + } + apply(&p, &mut ap); + let curvature = dot_n(&p, &ap); + if !curvature.is_finite() || curvature <= 0.0 { + return None; + } + let alpha = rz / curvature; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + if dot_n(&r, &r) <= tolerance2 { + apply(&x, &mut ap); + for i in 0..n { + ap[i] = rhs[i] - ap[i]; + } + return (x.iter().all(|v| v.is_finite()) && dot_n(&ap, &ap) <= 100.0 * tolerance2).then_some(x); + } + precondition(&r, &mut z); + let next_rz = dot_n(&r, &z); + let beta = next_rz / rz; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + rz = next_rz; + } + None +} + /// A similarity transform `x ↦ s R x + t`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Similarity { @@ -428,6 +527,143 @@ pub fn from_euler_xyz(e: V3) -> M3 { mod tests { use super::*; + fn dense_cholesky_reference(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { + for j in 0..n { + let mut d = a[j * n + j]; + for k in 0..j { + d -= a[j * n + k] * a[j * n + k]; + } + if d <= 0.0 || !d.is_finite() { + return false; + } + let d = d.sqrt(); + a[j * n + j] = d; + let (head, tail) = a.split_at_mut((j + 1) * n); + let rowj = &head[j * n..j * n + j]; + // Rows below j (sequential: per-column parallel dispatch costs more than it saves). + for row in tail.chunks_mut(n) { + row[j] = (row[j] - dot_n(&row[..j], rowj)) / d; + } + } + // Forward: L y = b. + for i in 0..n { + let mut s = b[i]; + for k in 0..i { + s -= a[i * n + k] * b[k]; + } + b[i] = s / a[i * n + i]; + } + // Back: Lᵀ x = y. + for i in (0..n).rev() { + let mut s = b[i]; + for k in i + 1..n { + s -= a[k * n + i] * b[k]; + } + b[i] = s / a[i * n + i]; + } + b.iter().all(|v| v.is_finite()) + } + + #[test] + fn banded_cholesky_is_bit_identical_to_dense() { + for n in [1usize, 2, 3, 4, 5, 6, 7, 8, 13, 31, 65, 127] { + for width in [0usize, 1, 2, 5, 11, n - 1] { + for seed in 0..3 { + for scale in [1.0, 1e-100, 1e100] { + let mut a = vec![0.0; n * n]; + for i in 0..n { + for j in 0..i { + let value = if i - j <= width { + (((i * 13 + j * 17 + seed) % 23) as f64 - 11.0) / 128.0 + } else if (i + j) % 2 == 0 { + -0.0 + } else { + 0.0 + }; + a[i * n + j] = value; + a[j * n + i] = value; + } + } + for i in 0..n { + a[i * n + i] = a[i * n..(i + 1) * n].iter().map(|v| v.abs()).sum::() + 1.0; + } + for value in &mut a { + *value *= scale; + } + let mut rhs: Vec = (0..n).map(|i| if i % 7 == 0 { -0.0 } else { ((i * 5 + seed) % 17) as f64 - 8.0 }).collect(); + let (mut reference_a, mut reference_rhs) = (a.clone(), rhs.clone()); + assert!(dense_cholesky_reference(&mut reference_a, n, &mut reference_rhs)); + assert!(cholesky_solve(&mut a, n, &mut rhs)); + assert_eq!( + rhs.iter().map(|v| v.to_bits()).collect::>(), + reference_rhs.iter().map(|v| v.to_bits()).collect::>(), + "n={n} width={width} seed={seed} scale={scale}" + ); + assert_eq!( + a.iter().map(|v| v.to_bits()).collect::>(), + reference_a.iter().map(|v| v.to_bits()).collect::>(), + "factor n={n} width={width}" + ); + } + } + } + } + } + + #[test] + fn banded_cholesky_preserves_underflow_and_signed_zero() { + let n = 8; + let mut a = vec![0.0; n * n]; + for i in 0..n { + for j in 0..n { + a[i * n + j] = if i == j { + 1e300 + } else if (i + j) % 2 == 0 { + -0.0 + } else { + 0.0 + }; + } + } + let mut rhs = vec![-1e-300, 1e-300, -0.0, 0.0, -1e-300, 1e-300, 0.0, -0.0]; + let (mut original, mut expected) = (a.clone(), rhs.clone()); + assert!(dense_cholesky_reference(&mut original, n, &mut expected)); + assert!(cholesky_solve(&mut a, n, &mut rhs)); + assert_eq!(rhs.iter().map(|v| v.to_bits()).collect::>(), expected.iter().map(|v| v.to_bits()).collect::>()); + } + + #[test] + fn cholesky_rejects_invalid_dimensions_without_indexing() { + assert!(!cholesky_solve(&mut [], usize::MAX, &mut [])); + assert!(!cholesky_solve(&mut [1.0], 2, &mut [1.0, 1.0])); + assert!(!cholesky_solve(&mut [1.0], 1, &mut [])); + assert!(cholesky_solve(&mut [], 0, &mut [])); + } + + #[test] + fn conjugate_gradient_checks_residual_and_bad_curvature() { + let n = 64; + let rhs: Vec = (0..n).map(|i| (i % 11) as f64 - 5.0).collect(); + let apply = |x: &[f64], y: &mut [f64]| { + for i in 0..n { + y[i] = 4.0 * x[i] + if i > 0 { x[i - 1] } else { 0.0 } + if i + 1 < n { x[i + 1] } else { 0.0 }; + } + }; + let x = conjugate_gradient(&rhs, apply, |r, z| { + for (r, z) in r.iter().zip(z) { + *z = *r / 4.0; + } + }) + .unwrap(); + let mut product = vec![0.0; n]; + apply(&x, &mut product); + let residual: f64 = rhs.iter().zip(product).map(|(b, ax)| (b - ax).powi(2)).sum(); + assert!(residual < 1e-18 * dot_n(&rhs, &rhs)); + assert!(conjugate_gradient(&[1.0], |x, y| y[0] = -x[0], |r, z| z.copy_from_slice(r)).is_none()); + assert!(conjugate_gradient(&[f64::NAN], |_, _| {}, |_, _| {}).is_none()); + assert_eq!(conjugate_gradient(&[0.0], |_, _| {}, |_, _| {}).unwrap(), vec![0.0]); + } + #[test] fn svd_reconstructs() { let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0]; @@ -441,6 +677,20 @@ mod tests { assert!(s[0] >= s[1] && s[1] >= s[2]); } + #[test] + fn null_vector_matches_full_svd_bits_including_ties() { + for (m, n) in [(0, 0), (1, 1), (3, 3), (8, 9), (9, 9), (20, 3), (100, 9)] { + for scale in [0.0, 1e-100, 1.0, 1e100] { + let a: Vec = (0..m * n).map(|i| (((i * 37) % 31) as f64 - 15.0) * scale).collect(); + let (_, _, v) = svd(&a, m, n); + let expected: Vec<_> = (0..n).map(|r| v[r * n + n - 1].to_bits()).collect(); + assert_eq!(null_vector(&a, m, n).iter().map(|v| v.to_bits()).collect::>(), expected); + } + } + let a = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + assert_eq!(null_vector(&a, 3, 3), vec![0.0, 0.0, 1.0]); + } + #[test] fn rotations_round_trip() { let w = [0.3, -1.2, 0.7]; diff --git a/crates/track/src/klt.rs b/crates/track/src/klt.rs index a4566c79a..c947bd4d1 100644 --- a/crates/track/src/klt.rs +++ b/crates/track/src/klt.rs @@ -254,6 +254,42 @@ impl Default for LkOpts { } } +/// Bilinear taps shared by every pixel of a `(2r+1)²` window centred at `c`: the fractional +/// offset is the same for the whole window, and the column/row indices are clamped like +/// [`Plane::at`] (edges repeated). Row indices are pre-multiplied by the plane width. +#[derive(Default)] +struct Taps { + xs: Vec<[usize; 2]>, + ys: Vec<[usize; 2]>, + t: [f32; 2], +} + +impl Taps { + fn set(&mut self, p: &Plane, c: [f64; 2], r: i32) { + debug_assert_eq!(p.ch, 1); + let (fx, fy) = (c[0] - 0.5, c[1] - 0.5); + let (x0, y0) = (fx.floor(), fy.floor()); + self.t = [(fx - x0) as f32, (fy - y0) as f32]; + const FAR: f64 = (1u64 << 40) as f64; + let (xi, yi) = (x0.clamp(-FAR, FAR) as isize, y0.clamp(-FAR, FAR) as isize); + let (wm, hm) = (p.w as isize - 1, p.h as isize - 1); + let r = r as isize; + self.xs.clear(); + self.ys.clear(); + self.xs.extend((-r..=r).map(|d| [(xi + d).clamp(0, wm) as usize, (xi + d + 1).clamp(0, wm) as usize])); + self.ys.extend((-r..=r).map(|d| [(yi + d).clamp(0, hm) as usize * p.w, (yi + d + 1).clamp(0, hm) as usize * p.w])); + } +} + +/// [`Plane::sample`]'s bilinear form on pre-resolved taps. +#[inline(always)] +fn bilerp(d: &[f32], x: [usize; 2], y: [usize; 2], t: [f32; 2]) -> f32 { + let (a, b, c, e) = (d[y[0] + x[0]], d[y[0] + x[1]], d[y[1] + x[0]], d[y[1] + x[1]]); + let top = a + (b - a) * t[0]; + let bot = c + (e - c) * t[0]; + top + (bot - top) * t[1] +} + fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> { let top = a.levels.len().min(b.levels.len()).checked_sub(1)?; let r = o.radius; @@ -263,7 +299,7 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize]; let mut wgx = win.clone(); let mut wgy = win.clone(); - let mut v1 = [0.0f32]; + let mut taps = Taps::default(); for l in (0..=top).rev() { let s = (1u32 << l) as f64; let (ia, ib) = (&a.levels[l], &b.levels[l]); @@ -274,15 +310,12 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & } let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64); let mut k = 0; - for dy in -r..=r { - for dx in -r..=r { - let (x, y) = (c[0] + dx as f64, c[1] + dy as f64); - ia.sample(x, y, &mut v1); - win[k] = v1[0]; - gx.sample(x, y, &mut v1); - wgx[k] = v1[0]; - gy.sample(x, y, &mut v1); - wgy[k] = v1[0]; + taps.set(ia, c, r); + for &y in &taps.ys { + for &x in &taps.xs { + win[k] = bilerp(&ia.data, x, y, taps.t); + wgx[k] = bilerp(&gx.data, x, y, taps.t); + wgy[k] = bilerp(&gy.data, x, y, taps.t); let (ix, iy) = (wgx[k] as f64, wgy[k] as f64); gxx += ix * ix; gxy += ix * iy; @@ -300,10 +333,10 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & for _ in 0..o.iterations { let (mut bx, mut by) = (0.0f64, 0.0f64); let mut k = 0; - for dy in -r..=r { - for dx in -r..=r { - ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1); - let diff = (win[k] - v1[0]) as f64; + taps.set(ib, [c[0] + g[0] + v[0], c[1] + g[1] + v[1]], r); + for &y in &taps.ys { + for &x in &taps.xs { + let diff = (win[k] - bilerp(&ib.data, x, y, taps.t)) as f64; bx += diff * wgx[k] as f64; by += diff * wgy[k] as f64; k += 1; @@ -397,6 +430,87 @@ mod tests { im } + /// The per-pixel `Plane::sample` implementation `lk_point` replaced, kept as the oracle. + fn lk_point_ref(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> { + let top = a.levels.len().min(b.levels.len()).checked_sub(1)?; + let r = o.radius; + let n = ((2 * r + 1) * (2 * r + 1)) as f64; + let s0 = (1u32 << top) as f64; + let mut g = [guess[0] / s0, guess[1] / s0]; + let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize]; + let mut wgx = win.clone(); + let mut wgy = win.clone(); + let mut v1 = [0.0f32]; + for l in (0..=top).rev() { + let s = (1u32 << l) as f64; + let (ia, ib) = (&a.levels[l], &b.levels[l]); + let (gx, gy) = &a.grads[l]; + let c = [p[0] / s, p[1] / s]; + if c[0] < -1.0 || c[1] < -1.0 || c[0] > ia.w as f64 + 1.0 || c[1] > ia.h as f64 + 1.0 { + return None; + } + let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64); + let mut k = 0; + for dy in -r..=r { + for dx in -r..=r { + let (x, y) = (c[0] + dx as f64, c[1] + dy as f64); + ia.sample(x, y, &mut v1); + win[k] = v1[0]; + gx.sample(x, y, &mut v1); + wgx[k] = v1[0]; + gy.sample(x, y, &mut v1); + wgy[k] = v1[0]; + let (ix, iy) = (wgx[k] as f64, wgy[k] as f64); + gxx += ix * ix; + gxy += ix * iy; + gyy += iy * iy; + k += 1; + } + } + let det = gxx * gyy - gxy * gxy; + let tr = 0.5 * (gxx + gyy); + let mineig = (tr - (0.25 * (gxx - gyy).powi(2) + gxy * gxy).sqrt()) / n; + if mineig < o.min_eigen || det.abs() < 1e-18 { + return None; + } + let mut v = [0.0f64; 2]; + for _ in 0..o.iterations { + let (mut bx, mut by) = (0.0f64, 0.0f64); + let mut k = 0; + for dy in -r..=r { + for dx in -r..=r { + ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1); + let diff = (win[k] - v1[0]) as f64; + bx += diff * wgx[k] as f64; + by += diff * wgy[k] as f64; + k += 1; + } + } + let nx = (gyy * bx - gxy * by) / det; + let ny = (gxx * by - gxy * bx) / det; + v[0] += nx; + v[1] += ny; + if !(v[0].is_finite() && v[1].is_finite()) { + return None; + } + if nx * nx + ny * ny < o.epsilon * o.epsilon { + break; + } + } + if l > 0 { + g = [2.0 * (g[0] + v[0]), 2.0 * (g[1] + v[1])]; + } else { + g = [g[0] + v[0], g[1] + v[1]]; + } + } + let q = [p[0] + g[0], p[1] + g[1]]; + let l0 = &b.levels[0]; + if q[0] < 0.0 || q[1] < 0.0 || q[0] > l0.w as f64 || q[1] > l0.h as f64 { + return None; + } + Some(q) + } + #[test] fn klt_recovers_a_shift() { let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3); @@ -411,6 +525,35 @@ mod tests { assert!(errs[errs.len() / 2] < 0.1, "median error {}", errs[errs.len() / 2]); } + #[test] + fn window_taps_match_per_pixel_sampling_bit_for_bit() { + let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3); + let b = GrayPyramid::from_image(&img(6.3, -4.2), [0.0; 2], 1, 3); + let mut pts = vec![]; + for y in (-3..125).step_by(3) { + for x in (-3..165).step_by(3) { + pts.push([x as f64 + 0.37 * ((x * 7 + y) % 3) as f64, y as f64 + 0.5 * ((x + y * 5) % 2) as f64]); + } + } + pts.extend([[0.0, 0.0], [160.0, 120.0], [80.0, 60.0], [0.5, 0.5], [159.5, 119.5]]); + let (mut tracked, mut lost) = (0, 0); + for (i, p) in pts.iter().enumerate() { + for o in [LkOpts::default(), LkOpts { radius: 3, iterations: 5, ..Default::default() }] { + let g = [(i % 5) as f64 - 2.0, (i % 3) as f64 - 1.0]; + let (new, old) = (lk_point(&a, &b, *p, g, &o), lk_point_ref(&a, &b, *p, g, &o)); + match (new, old) { + (Some(n), Some(o)) => { + assert_eq!([n[0].to_bits(), n[1].to_bits()], [o[0].to_bits(), o[1].to_bits()], "{p:?}"); + tracked += 1; + } + (None, None) => lost += 1, + (n, o) => panic!("{p:?}: {n:?} vs {o:?}"), + } + } + } + assert!(tracked > 500 && lost > 0, "{tracked} tracked, {lost} lost"); + } + #[test] fn features_respect_the_region_and_spacing() { let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 2); diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index fe36bc096..8867bfcb8 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -201,7 +201,7 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 { } let ch = p.ch; let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64); - let identity = (a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9; + let identity = is_identity(a); let fx = c[0] - 0.5; let fy = c[1] - 0.5; let (bx, by) = (fx.floor(), fy.floor()); @@ -256,12 +256,114 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 { (st / (var * p.ss).sqrt()).clamp(-1.0, 1.0) } +fn is_identity(a: [f64; 2]) -> bool { + (a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9 +} + +/// The bilinear taps of every template pixel at `c + A u`. Candidates `c + (i, j)` with integer +/// `(i, j)` give each pixel the same fractional offsets, so one table serves them all and a +/// candidate is integer-indexed lerps (the arithmetic of `Plane::sample`) with one bounds check. +struct Taps { + /// Offset (in samples) of the top-left tap per template pixel from the one at `base`. + off: Vec, + w: Vec<[f32; 2]>, + base: isize, + /// Tap bounding box (x0, y0, x1, y1) in plane pixels at the reference, `+1` taps included. + bb: [isize; 4], +} + +impl Taps { + fn new(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> Option { + let n = ((2 * p.r[0] + 1) * (2 * p.r[1] + 1)) as usize; + let (mut off, mut w) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let (rx, ry) = ((c[0] - 0.5).floor(), (c[1] - 0.5).floor()); + if !(rx.abs() < 1e9 && ry.abs() < 1e9) { + return None; + } + let (rx, ry) = (rx as isize, ry as isize); + let mut bb = [isize::MAX, isize::MAX, isize::MIN, isize::MIN]; + for gy in -p.r[1]..=p.r[1] { + for gx in -p.r[0]..=p.r[0] { + let (ux, uy) = (gx as f64, gy as f64); + let fx = c[0] + a[0] * ux - a[1] * uy - 0.5; + let fy = c[1] + a[1] * ux + a[0] * uy - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + if !(x0.abs() < 1e9 && y0.abs() < 1e9) { + return None; + } + let (dx, dy) = (x0 as isize - rx, y0 as isize - ry); + bb = [bb[0].min(dx), bb[1].min(dy), bb[2].max(dx + 1), bb[3].max(dy + 1)]; + off.push((dy * plane.w as isize + dx) * plane.ch as isize); + w.push([(fx - x0) as f32, (fy - y0) as f32]); + } + } + let base = (ry * plane.w as isize + rx) * plane.ch as isize; + Some(Taps { off, w, base, bb: [bb[0] + rx, bb[1] + ry, bb[2] + rx, bb[3] + ry] }) + } + + /// The correlation at `c + (i, j)`, or `None` when a tap leaves the plane. + fn ncc(&self, plane: &Plane, p: &Patch, i: isize, j: isize) -> Option { + if self.bb[0] + i < 0 || self.bb[1] + j < 0 || self.bb[2] + i >= plane.w as isize || self.bb[3] + j >= plane.h as isize { + return None; + } + let ch = p.ch; + let row = plane.w * ch; + let b = self.base + (j * plane.w as isize + i) * ch as isize; + let d = &plane.data; + let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64); + let mut k = 0; + for (o, t) in self.off.iter().zip(&self.w) { + let i00 = (b + o) as usize; + let (i10, i01) = (i00 + ch, i00 + row); + for cc in 0..ch { + let top = d[i00 + cc] + (d[i10 + cc] - d[i00 + cc]) * t[0]; + let bot = d[i01 + cc] + (d[i01 + ch + cc] - d[i01 + cc]) * t[0]; + let x = (top + (bot - top) * t[1]) as f64; + s += x; + s2 += x * x; + st += x * p.zm[k] as f64; + k += 1; + } + } + let n = p.zm.len() as f64; + let var = s2 - s * s / n; + if var <= 1e-12 { + return Some(0.0); + } + Some((st / (var * p.ss).sqrt()).clamp(-1.0, 1.0)) + } +} + +/// `ncc` of one template at integer offsets from a reference centre, through a [`Taps`] table +/// when the shape is not the identity (the identity has its own fast path in `ncc`). +struct Scorer<'a> { + plane: &'a Plane, + p: &'a Patch, + c: [f64; 2], + a: [f64; 2], + taps: Option, +} + +impl<'a> Scorer<'a> { + fn new(plane: &'a Plane, p: &'a Patch, c: [f64; 2], a: [f64; 2]) -> Scorer<'a> { + let taps = if is_identity(a) || p.ss <= 1e-12 { None } else { Taps::new(plane, p, c, a) }; + Scorer { plane, p, c, a, taps } + } + + /// The correlation at the reference centre plus `(i, j)` pixels. + fn at(&self, i: i32, j: i32) -> f64 { + self.taps + .as_ref() + .and_then(|t| t.ncc(self.plane, self.p, i as isize, j as isize)) + .unwrap_or_else(|| ncc(self.plane, self.p, [self.c[0] + i as f64, self.c[1] + j as f64], self.a)) + } +} + /// The pre-processed pixels around one point: a pyramid of a crop. Plane coordinates are image /// pixels minus `origin`, divided by `2^level`. struct Crop { origin: [f64; 2], levels: Vec, - grad0: (Plane, Plane), } impl Crop { @@ -280,13 +382,12 @@ impl Crop { if opts.enhance { p = p.enhance(); } - let grad0 = p.gradients(); let mut lv = vec![p]; for _ in 0..levels { let d = lv.last().map(Plane::downsample).unwrap_or_default(); lv.push(d); } - Crop { origin: [x0, y0], levels: lv, grad0 } + Crop { origin: [x0, y0], levels: lv } } fn to_level(&self, p: [f64; 2], k: usize) -> [f64; 2] { let s = (1u32 << k) as f64; @@ -298,6 +399,12 @@ impl Crop { } } +/// Largest distance (in top-level pixels, per axis) between the best coarse match and the +/// motion prediction for which the fine stage searches only ±1 pixel per level. +const SMALL_MOTION: f64 = 1.0; +/// Lowest coarse NCC score of the best match for which the fine stage may search only ±1. +const RELIABLE_SCORE: f64 = 0.8; + /// Per-point tracking state. #[derive(Clone, Debug)] struct PointState { @@ -372,13 +479,15 @@ impl PointState { let (rx, ry) = ((range[0] / s).ceil() as i32, (range[1] / s).ceil() as i32); let plane = &crop.levels[top]; let tpl = &self.templates[top]; + let scorer = Scorer::new(plane, tpl, ctop, self.shape); let shape = self.shape; let mut cands: Vec<(f64, [f64; 2])> = (-ry..=ry) .into_par_iter() .flat_map_iter(|j| { + let scorer = &scorer; (-rx..=rx).map(move |i| { let c = [ctop[0] + i as f64, ctop[1] + j as f64]; - (ncc(plane, tpl, c, shape), c) + (scorer.at(i, j), c) }) }) .collect(); @@ -396,7 +505,10 @@ impl PointState { break; } } - // Fine: refine each seed down the pyramid in a 5 × 5 neighbourhood. + // Fine: refine each seed down the pyramid. A good motion prediction plus a reliable coarse + // match only needs a 3 × 3 neighbourhood per level; otherwise keep 5 × 5. + let near = cands[0].0 >= RELIABLE_SCORE && seeds.first().is_some_and(|c| (c[0] - cp[0]).abs() <= SMALL_MOTION && (c[1] - cp[1]).abs() <= SMALL_MOTION); + let rr: i32 = if near { 1 } else { 2 }; let mut best = (f64::MIN, sc); for seed in seeds { let mut c = seed; @@ -405,14 +517,15 @@ impl PointState { c = [c[0] * 2.0, c[1] * 2.0]; let (pl, tp) = (&crop.levels[k], &self.templates[k]); // Start from the projected match; neighbours must beat it (no drift on ties). - let mut lb = (ncc(pl, tp, c, shape), c); - for j in -2..=2 { - for i in -2..=2 { + let scorer = Scorer::new(pl, tp, c, shape); + let mut lb = (scorer.at(0, 0), c); + for j in -rr..=rr { + for i in -rr..=rr { if i == 0 && j == 0 { continue; } let q = [c[0] + i as f64, c[1] + j as f64]; - let v = ncc(pl, tp, q, shape); + let v = scorer.at(i, j); if v > lb.0 + 1e-9 { lb = (v, q); } @@ -431,15 +544,16 @@ impl PointState { // Sub-pixel (and shape) refinement. let (mut c, mut a) = (c0, shape); if opts.subpixel { + let mut grad = Grad::around(&crop.levels[0], c0, shape, self.templates[0].r); let mut ok = false; if opts.track_shape - && let Some((cr, ar)) = lk_refine(&crop, &self.templates[0], c0, shape, true) + && let Some((cr, ar)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, true) { (c, a) = (cr, ar); ok = true; } if !ok { - if let Some((cr, _)) = lk_refine(&crop, &self.templates[0], c0, shape, false) { + if let Some((cr, _)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, false) { c = cr; } else { c = parabolic(&crop.levels[0], &self.templates[0], c0, shape); @@ -506,12 +620,52 @@ fn parabolic(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> [f64; 2] { [c[0] + off(f(-1.0, 0.0), f(1.0, 0.0)), c[1] + off(f(0.0, -1.0), f(0.0, 1.0))] } +/// Level-0 gradients of a crop, computed only where `lk_refine` reads them: valid on the pixel +/// rectangle `valid` (x0, y0, x1, y1, inclusive) and zero elsewhere. +struct Grad { + gx: Plane, + gy: Plane, + valid: [isize; 4], +} + +impl Grad { + /// Extra pixels around the first window of taps, for the drift of the refinement. A window that + /// still runs out is replaced by the full gradients. + const MARGIN: isize = 6; + + fn full(plane: &Plane) -> Grad { + let (gx, gy) = plane.gradients(); + Grad { gx, gy, valid: [isize::MIN, isize::MIN, isize::MAX, isize::MAX] } + } + + fn around(plane: &Plane, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> Grad { + let [x0, y0, x1, y1] = Grad::taps(c, a, r); + let m = Grad::MARGIN; + let clip = |v: isize, hi: usize| v.clamp(0, (hi as isize - 1).max(0)) as usize; + let rect = [clip(x0 - m, plane.w), clip(y0 - m, plane.h), clip(x1 + m, plane.w), clip(y1 + m, plane.h)]; + let (gx, gy) = plane.gradients_in(rect); + Grad { gx, gy, valid: rect.map(|v| v as isize) } + } + + /// The pixels read by sampling the template window at `c` with shape `a` (bilinear taps). + fn taps(c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> [isize; 4] { + let ex = (a[0].abs() * r[0] as f64 + a[1].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9; + let ey = (a[1].abs() * r[0] as f64 + a[0].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9; + let px = |v: f64| v.clamp(-1e9, 1e9).floor() as isize; + [px(c[0] - ex - 0.5), px(c[1] - ey - 0.5), px(c[0] + ex - 0.5) + 1, px(c[1] + ey - 0.5) + 1] + } + + fn covers(&self, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> bool { + let t = Grad::taps(c, a, r); + t[0] >= self.valid[0] && t[1] >= self.valid[1] && t[2] <= self.valid[2] && t[3] <= self.valid[3] + } +} + /// Forward-additive Lucas–Kanade (Gauss–Newton) alignment of the level-0 template with gain and /// bias: minimise Σ (I(c + A u) − g T(u) − b)² over the centre `c` and, with `shape`, the /// similarity `A`. Returns `None` when it diverges. -fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> { +fn lk_refine(crop: &Crop, grad: &mut Grad, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> { let plane = &crop.levels[0]; - let (gxp, gyp) = &crop.grad0; let ch = p.ch; let (mut c, mut a) = (c0, a0); let mut g = 1.0f64; @@ -546,6 +700,10 @@ fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> let np = if shape { 6 } else { 4 }; let (mut v, mut vx, mut vy) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]); for _ in 0..30 { + if !grad.covers(c, a, p.r) { + *grad = Grad::full(plane); + } + let (gxp, gyp) = (&grad.gx, &grad.gy); let mut h = [[0.0f64; 6]; 6]; let mut rhs = [0.0f64; 6]; let mut k = 0; diff --git a/crates/track/src/mask.rs b/crates/track/src/mask.rs index e1e133409..898c80f28 100644 --- a/crates/track/src/mask.rs +++ b/crates/track/src/mask.rs @@ -44,6 +44,19 @@ pub fn bounds(p: &[[f64; 2]]) -> [f64; 4] { b } +/// Crop `img` to `region` (layer pixels), with the layer offset of the crop's origin: the part +/// of the frame the tracker reads. `None` when the region leaves too little of the frame. +fn crop_region(img: &Image, off: [f64; 2], region: [f64; 4]) -> Option<(Image, [f64; 2])> { + let x0 = (region[0] + off[0]).floor().max(0.0) as i64; + let y0 = (region[1] + off[1]).floor().max(0.0) as i64; + let x1 = ((region[2] + off[0]).ceil() as i64).min(img.width as i64); + let y1 = ((region[3] + off[1]).ceil() as i64).min(img.height as i64); + if x1 <= x0 + 8 || y1 <= y0 + 8 { + return None; + } + Some((img.crop(x0, y0, (x1 - x0) as u32, (y1 - y0) as u32), [off[0] - x0 as f64, off[1] - y0 as f64])) +} + /// Mask tracker state. #[derive(Clone, Debug)] pub struct MaskTracker { @@ -52,8 +65,8 @@ pub struct MaskTracker { pub outline: Vec<[f64; 2]>, /// The last frame-to-frame motion (a constant-velocity guess for the next frame). velocity: Homography, - prev: Option, - prev_offset: [f64; 2], + /// The previous frame cropped to the region the next step reads, with its layer offset. + prev: Option<(Image, [f64; 2])>, /// Features used on the last step (layer pixels), for display and tests. pub last_features: usize, } @@ -72,44 +85,39 @@ pub struct MaskStep { impl MaskTracker { /// Start on `frame` with the mask `outline` (a closed polyline in layer pixels). pub fn new(model: Model, outline: Vec<[f64; 2]>, frame: &Frame) -> MaskTracker { - MaskTracker { model, outline, velocity: Homography::IDENTITY, prev: Some(frame.img.clone()), prev_offset: frame.offset, last_features: 0 } + let mut t = MaskTracker { model, outline, velocity: Homography::IDENTITY, prev: None, last_features: 0 }; + t.prev = t.region().and_then(|r| crop_region(frame.img, frame.offset, r)); + t + } + + /// Crop region (layer pixels) of the next step: the mask plus room for its motion. + fn region(&self) -> Option<[f64; 4]> { + let b = bounds(&self.outline); + if !(b[0].is_finite() && b[2] > b[0] && b[3] > b[1]) { + return None; + } + let margin = (0.35 * (b[2] - b[0]).max(b[3] - b[1])).max(32.0); + Some([b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin]) } /// Track into `frame` (the next frame in either direction). `None` when the mask's pixels /// can't be followed (no texture, or the mask left the frame). pub fn step(&mut self, frame: &Frame) -> Option { - let prev = self.prev.take()?; - let res = self.step_inner(&prev, self.prev_offset, frame); - self.prev = Some(frame.img.clone()); - self.prev_offset = frame.offset; - let r = res?; - self.outline = self.outline.iter().map(|p| r.motion.apply(*p)).collect(); - self.velocity = r.motion; - Some(r) + let (ca, oa) = self.prev.take()?; + let res = self.step_inner(ca, oa, frame); + if let Some(r) = &res { + self.outline = self.outline.iter().map(|p| r.motion.apply(*p)).collect(); + self.velocity = r.motion; + } + self.prev = self.region().and_then(|r| crop_region(frame.img, frame.offset, r)); + res } - fn step_inner(&mut self, prev: &Image, prev_off: [f64; 2], frame: &Frame) -> Option { + fn step_inner(&mut self, ca: Image, oa: [f64; 2], frame: &Frame) -> Option { let b = bounds(&self.outline); - if !(b[0].is_finite() && b[2] > b[0] && b[3] > b[1]) { - return None; - } let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - // Crop region (layer pixels): the mask plus room for its motion. - let margin = (0.35 * bw.max(bh)).max(32.0); - let (lx0, ly0, lx1, ly1) = (b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin); - // Both frames share one plane coordinate system: crop in the frames' image pixels. - let crop = |img: &Image, off: [f64; 2]| { - let x0 = (lx0 + off[0]).floor().max(0.0) as i64; - let y0 = (ly0 + off[1]).floor().max(0.0) as i64; - let x1 = ((lx1 + off[0]).ceil() as i64).min(img.width as i64); - let y1 = ((ly1 + off[1]).ceil() as i64).min(img.height as i64); - if x1 <= x0 + 8 || y1 <= y0 + 8 { - return None; - } - Some((img.crop(x0, y0, (x1 - x0) as u32, (y1 - y0) as u32), [off[0] - x0 as f64, off[1] - y0 as f64])) - }; - let (ca, oa) = crop(prev, prev_off)?; - let (cb, ob) = crop(frame.img, frame.offset)?; + // Both frames share one plane coordinate system: the crops are in image pixels. + let (cb, ob) = crop_region(frame.img, frame.offset, self.region()?)?; let factor = analysis_factor(ca.width.max(cb.width), ca.height.max(cb.height), 720); // Pyramids must share coordinates: use a common origin (layer pixels → plane). let levels = 3; @@ -219,4 +227,20 @@ mod tests { assert!(err < 1.5, "frame {f}: max vertex error {err}"); } } + + #[test] + fn follows_the_disk_through_a_frame_offset() { + let off = [40.0, -25.0]; + let pose = |f: usize| Homography([[1.0, 0.0, 140.0 + 5.0 * f as f64], [0.0, 1.0, 110.0 - 3.0 * f as f64], [0.0, 0.0, 1.0]]); + let ring: Vec<[f64; 2]> = (0..24).map(|i| (i as f64 * 15.0).to_radians()).map(|a| [40.0 * a.cos(), 40.0 * a.sin()]).collect(); + let layer = |f: usize| -> Vec<[f64; 2]> { ring.iter().map(|p| pose(f).apply(*p)).map(|q| [q[0] - off[0], q[1] - off[1]]).collect() }; + let f0 = frame(&pose(0)); + let mut mt = MaskTracker::new(Model::Translation, layer(0), &Frame { img: &f0, offset: off }); + for f in 1..6 { + let img = frame(&pose(f)); + mt.step(&Frame { img: &img, offset: off }).expect("tracked"); + let err = mt.outline.iter().zip(&layer(f)).map(|(a, b)| (a[0] - b[0]).hypot(a[1] - b[1])).fold(0.0, f64::max); + assert!(err < 1.0, "frame {f}: max vertex error {err}"); + } + } } diff --git a/crates/track/src/plane.rs b/crates/track/src/plane.rs index bd2d987d6..ff15d08b2 100644 --- a/crates/track/src/plane.rs +++ b/crates/track/src/plane.rs @@ -191,15 +191,32 @@ impl Plane { /// Central-difference gradients `(d/dx, d/dy)`. pub fn gradients(&self) -> (Plane, Plane) { + self.gradients_in([0, 0, self.w.saturating_sub(1), self.h.saturating_sub(1)]) + } + + /// The gradients of the pixels in `[x0, y0, x1, y1]` (inclusive, within the plane); all other + /// pixels of the returned planes stay zero. + pub fn gradients_in(&self, [x0, y0, x1, y1]: [usize; 4]) -> (Plane, Plane) { let mut gx = Plane::new(self.w, self.h, self.ch); let mut gy = Plane::new(self.w, self.h, self.ch); - for y in 0..self.h { - for x in 0..self.w { - for c in 0..self.ch { - let (xi, yi) = (x as isize, y as isize); - let i = self.idx(x, y) + c; - gx.data[i] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c)); - gy.data[i] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c)); + if self.w == 0 || self.h == 0 { + return (gx, gy); + } + let (ch, row) = (self.ch, self.w * self.ch); + for y in y0..=y1.min(self.h - 1) { + for x in x0..=x1.min(self.w - 1) { + let i = self.idx(x, y); + if x > 0 && y > 0 && x + 1 < self.w && y + 1 < self.h { + for c in i..i + ch { + gx.data[c] = 0.5 * (self.data[c + ch] - self.data[c - ch]); + gy.data[c] = 0.5 * (self.data[c + row] - self.data[c - row]); + } + } else { + for c in 0..ch { + let (xi, yi) = (x as isize, y as isize); + gx.data[i + c] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c)); + gy.data[i + c] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c)); + } } } } @@ -240,4 +257,27 @@ mod tests { assert_eq!(Plane::from_image(&img, Channel::Saturation, 0, 0, 2, 2).data[0], 1.0); assert_eq!(Channel::from_name("luminance"), Some(Channel::Luminance)); } + + #[test] + fn gradient_window_and_interior_path_match_the_clamped_definition() { + let mut p = Plane::new(11, 9, 3); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 101) as f32 / 37.0; + } + let (gx, gy) = p.gradients(); + let rect = [2, 1, 8, 6]; + let (wx, wy) = p.gradients_in(rect); + for y in 0..p.h { + for x in 0..p.w { + for c in 0..3 { + let (xi, yi, i) = (x as isize, y as isize, p.idx(x, y) + c); + assert_eq!(gx.data[i].to_bits(), (0.5 * (p.at(xi + 1, yi, c) - p.at(xi - 1, yi, c))).to_bits()); + assert_eq!(gy.data[i].to_bits(), (0.5 * (p.at(xi, yi + 1, c) - p.at(xi, yi - 1, c))).to_bits()); + let inside = (rect[0]..=rect[2]).contains(&x) && (rect[1]..=rect[3]).contains(&y); + assert_eq!(wx.data[i], if inside { gx.data[i] } else { 0.0 }); + assert_eq!(wy.data[i], if inside { gy.data[i] } else { 0.0 }); + } + } + } + } } diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 0948d3fe0..103d695dd 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -339,38 +339,76 @@ pub fn rgb_of(img: &Image) -> Vec<[f32; 3]> { img.data.par_iter().map(|p| if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] }).collect() } -/// Pixels covered by `strokes` (of the given kinds) at `scale`. -pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec { - let mut m = vec![false; w * h]; - for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) { - let r = (s.radius * scale).max(0.5); - let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect(); - let segs: Vec<([f64; 2], [f64; 2])> = if pts.len() == 1 { vec![(pts[0], pts[0])] } else { pts.windows(2).map(|w| (w[0], w[1])).collect() }; - for (a, b) in segs { - let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize; - let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w); - let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize; - let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h); - let d = [b[0] - a[0], b[1] - a[1]]; - let l2 = d[0] * d[0] + d[1] * d[1]; - for y in y0..y1 { - for x in x0..x1 { - let p = [x as f64 + 0.5, y as f64 + 0.5]; - let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let q = [a[0] + d[0] * t, a[1] + d[1] * t]; - if (p[0] - q[0]).hypot(p[1] - q[1]) <= r { - m[y * w + x] = true; - } +/// A stroke's radius and segments at `scale`. +fn segments(s: &Stroke, scale: f64) -> (f64, Vec<([f64; 2], [f64; 2])>) { + let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect(); + let segs = match pts.as_slice() { + [p] => vec![(*p, *p)], + _ => pts.windows(2).map(|w| (w[0], w[1])).collect(), + }; + ((s.radius * scale).max(0.5), segs) +} + +/// Distance from `p` to the segment `a`–`b`. +fn segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let d = [b[0] - a[0], b[1] - a[1]]; + let l2 = d[0] * d[0] + d[1] * d[1]; + let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - a[0] - d[0] * t).hypot(p[1] - a[1] - d[1] * t) +} + +/// Call `paint` with every pixel `s` covers at `scale`. +fn sweep(s: &Stroke, scale: f64, w: usize, h: usize, mut paint: impl FnMut(usize)) { + let (r, segs) = segments(s, scale); + for (a, b) in segs { + let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize; + let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w); + let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize; + let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h); + for y in y0..y1 { + for x in x0..x1 { + if segment_distance([x as f64 + 0.5, y as f64 + 0.5], a, b) <= r { + paint(y * w + x); } } } } +} + +/// Pixels covered by `strokes` (of the given kinds) at `scale`. +pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec { + let mut m = vec![false; w * h]; + for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) { + sweep(s, scale, w, h, |i| m[i] = true); + } m } -/// Hard constraints: 1 = foreground, 2 = background, 0 = free. -const HARD_FG: u8 = 1; -const HARD_BG: u8 = 2; +/// What a pair of opposing strokes (add / remove) leaves on each pixel. +const ON: u8 = 1; +const OFF: u8 = 2; + +/// [`ON`] where an `on` stroke covers a pixel, [`OFF`] where an `off` stroke does, 0 elsewhere. +/// Strokes apply in drawing order, so where they overlap the last one painted wins: a stroke +/// corrects the ones before it. +pub fn resolve(strokes: &[&Stroke], on: StrokeKind, off: StrokeKind, scale: f64, w: usize, h: usize) -> Vec { + let mut m = vec![0; w * h]; + for s in strokes { + let v = if s.kind == on { + ON + } else if s.kind == off { + OFF + } else { + continue; + }; + sweep(s, scale, w, h, |i| m[i] = v); + } + m +} + +/// Hard constraints: foreground, background, else free. +const HARD_FG: u8 = ON; +const HARD_BG: u8 = OFF; /// Graph-cut energy weights. const GAMMA: f64 = 50.0; @@ -662,21 +700,9 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S return FrameSeg::empty(w, h); } let rgb = rgb_of(img); - let fgs = rasterize(strokes, &[StrokeKind::Fg], opts.scale, w, h); - let bgs = rasterize(strokes, &[StrokeKind::Bg], opts.scale, w, h); - let hard: Vec = fgs - .iter() - .zip(&bgs) - .map(|(f, b)| { - if *b { - HARD_BG - } else if *f { - HARD_FG - } else { - 0 - } - }) - .collect(); + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let fgs: Vec = hard.iter().map(|v| *v == HARD_FG).collect(); + let bgs: Vec = hard.iter().map(|v| *v == HARD_BG).collect(); // A warped matte is uncertain by about the search radius: blur it. A model's matte is not. let soft_prior: Option> = prior.map(|p| if p.weight > PRIOR { p.soft.clone() } else { matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)) }); @@ -788,8 +814,7 @@ fn core_of(soft: &[f32], w: usize, h: usize, r: f64) -> Vec { fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec, strokes: &[&Stroke], warped_refine: Option<&[u8]>, opts: &SegOpts, refine_radius: f64) -> FrameSeg { let (w, h) = (_img.width as usize, _img.height as usize); let (fg, bg) = fit_models(rgb, &labels); - let add = rasterize(strokes, &[StrokeKind::Refine], opts.scale, w, h); - let erase = rasterize(strokes, &[StrokeKind::RefineErase], opts.scale, w, h); + let painted = resolve(strokes, StrokeKind::Refine, StrokeKind::RefineErase, opts.scale, w, h); let near: Option> = warped_refine.filter(|r| r.iter().any(|v| *v != 0)).map(|_| matting::signed_distance(&labels, w, h)); let rr = (refine_radius * opts.scale + 2.0) as f32; let refine: Vec = (0..w * h) @@ -798,7 +823,7 @@ fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec, strokes: &[&Stroke], (Some(r), Some(sd)) => r[i] != 0 && sd[i].abs() <= rr, _ => false, }; - ((carried || add[i]) && !erase[i]) as u8 + ((carried && painted[i] != OFF) || painted[i] == ON) as u8 }) .collect(); FrameSeg { w, h, matte: labels, refine, fg, bg } @@ -877,21 +902,66 @@ fn points_along(s: &Stroke, scale: f64, max: usize) -> Vec<[f32; 2]> { out } -/// A model's prompt from the strokes: points along the foreground and background strokes, and -/// the box of a [`StrokeKind::Box`] stroke (matte pixels). -pub fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt { +/// A model's prompt from the strokes: points along the foreground and background strokes, but +/// not where a later stroke of the other kind painted over them (`hard`, from [`resolve`]), and +/// the box of a [`StrokeKind::Box`] stroke (matte pixels). A stroke painted over at every point +/// sampled along it, but not everywhere, is prompted from the middle of what is left of it. +pub fn stroke_prompt(strokes: &[&Stroke], hard: &[u8], scale: f64, w: usize, h: usize) -> Prompt { let mut points = vec![]; - for (kind, fg) in [(StrokeKind::Fg, true), (StrokeKind::Bg, false)] { + for (kind, own, other) in [(StrokeKind::Fg, HARD_FG, HARD_BG), (StrokeKind::Bg, HARD_BG, HARD_FG)] { let ss: Vec<&&Stroke> = strokes.iter().filter(|s| s.kind == kind).collect(); let per = (16 / ss.len().max(1)).max(1); for s in ss { - points.extend(points_along(s, scale, per).into_iter().map(|p| (p, fg))); + let along = points_along(s, scale, per); + let mut kept: Vec<[f32; 2]> = along.iter().copied().filter(|p| owner(hard, w, h, *p) != Some(other)).collect(); + if kept.is_empty() && !along.is_empty() { + kept.extend(deepest_owned(s, hard, own, scale, w, h)); + } + points.extend(kept.into_iter().map(|p| (p, kind == StrokeKind::Fg))); } } let bbox = strokes.iter().filter(|s| s.kind == StrokeKind::Box).find_map(|s| box_of(s, scale)); Prompt { points, bbox, ..Default::default() } } +/// The value of `hard` under the point `p` (`None` outside the frame). +fn owner(hard: &[u8], w: usize, h: usize, p: [f32; 2]) -> Option { + if !(p[0] >= 0.0 && p[1] >= 0.0) { + return None; + } + let (x, y) = (p[0] as usize, p[1] as usize); + if x >= w || y >= h { + return None; + } + hard.get(y * w + x).copied() +} + +/// The centre of the pixel deepest inside what `s` still owns (where `hard` is `own` under it), +/// if anything. +fn deepest_owned(s: &Stroke, hard: &[u8], own: u8, scale: f64, w: usize, h: usize) -> Option<[f32; 2]> { + let mut owned = vec![]; + sweep(s, scale, w, h, |i| { + if hard.get(i) == Some(&own) { + owned.push((i % w, i / w)); + } + }); + let (x0, x1) = (owned.iter().map(|p| p.0).min()?, owned.iter().map(|p| p.0).max()?); + let (y0, y1) = (owned.iter().map(|p| p.1).min()?, owned.iter().map(|p| p.1).max()?); + // One pixel of margin all round, so depth is measured to the nearest pixel it doesn't own. + let (cw, ch) = (x1 - x0 + 3, y1 - y0 + 3); + let at = |(x, y): (usize, usize)| (y - y0 + 1) * cw + (x - x0 + 1); + let mut free = vec![true; cw * ch]; + for p in &owned { + if let Some(v) = free.get_mut(at(*p)) { + *v = false; + } + } + let depth = matting::distance(&free, cw, ch); + let d = |p: &(usize, usize)| depth.get(at(*p)).copied().unwrap_or(0.0); + let (x, y) = owned.iter().copied().max_by(|a, b| d(a).total_cmp(&d(b)))?; + Some([x as f32 + 0.5, y as f32 + 0.5]) +} + /// The box `[x0, y0, x1, y1]` (matte pixels) of a box stroke: its first two points as opposite /// corners; `None` when they are missing, not finite or the box is empty. fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> { @@ -901,8 +971,18 @@ fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> { } /// Add a prior matte to a prompt: its box (a little larger; unless the prompt has one), up to -/// four points spread through its core, and the matte itself. -fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bool { +/// four points spread through its core, and the matte itself, all with this frame's strokes +/// (`hard`, from [`resolve`]) painted over it. +fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], hard: &[u8], w: usize, h: usize) -> bool { + let soft: Vec = soft + .iter() + .enumerate() + .map(|(i, v)| match hard.get(i) { + Some(&HARD_FG) => 1.0, + Some(&HARD_BG) => 0.0, + _ => *v, + }) + .collect(); let bin: Vec = soft.iter().map(|v| (*v >= 0.5) as u8).collect(); let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); for (i, v) in bin.iter().enumerate() { @@ -932,7 +1012,7 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo } } prompt.points.extend(picks.into_iter().map(|i| ([(i % w) as f32 + 0.5, (i / w) as f32 + 0.5], true))); - prompt.mask = Some(soft.to_vec()); + prompt.mask = Some(soft); true } @@ -941,9 +1021,10 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo pub fn segment_with(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64, model: Option<&dyn MaskModel>) -> FrameSeg { let (w, h) = (img.width as usize, img.height as usize); let Some(m) = model.filter(|_| w > 0 && h > 0) else { return segment(img, strokes, prior, opts, refine_radius) }; - let mut prompt = stroke_prompt(strokes, opts.scale); + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h); if let Some(p) = prior { - add_prior_prompt(&mut prompt, &p.soft, w, h); + add_prior_prompt(&mut prompt, &p.soft, &hard, w, h); } if !prompt.points.iter().any(|(_, fg)| *fg) { return segment(img, strokes, prior, opts, refine_radius); @@ -973,8 +1054,9 @@ pub fn propagate_with( let (w, h) = (next_img.width as usize, next_img.height as usize); let Some(m) = model.filter(|_| w > 0 && h > 0) else { return propagate(prev_img, prev, next_img, strokes, opts, refine_radius) }; let prior = warp_prior(prev_img, prev, next_img, opts); - let mut prompt = stroke_prompt(strokes, opts.scale); - if !add_prior_prompt(&mut prompt, &prior.soft, w, h) { + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h); + if !add_prior_prompt(&mut prompt, &prior.soft, &hard, w, h) { return propagate(prev_img, prev, next_img, strokes, opts, refine_radius); } match m.segment(&rgb_of(next_img), w, h, &prompt) { diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index 92c7bec04..8a5cab98c 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -182,6 +182,28 @@ fn a_correction_stroke_fixes_a_bad_frame() { assert!(iou(&next.matte, >2) > 0.93); } +/// A stroke corrects the ones painted before it: foreground over an earlier background stroke +/// restores the area, and background over that removes it again. +#[test] +fn later_strokes_override_earlier_ones() { + let c = center(0); + let (img, gt) = frame_at(c); + let spot = [c[0], c[1] + 20.0]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let red = line(StrokeKind::Bg, 0, [spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]], 6.0); + let green = Stroke { kind: StrokeKind::Fg, ..red.clone() }; + let seg = |st: &[Stroke]| segment(&img, &st.iter().collect::>(), None, &SegOpts::default(), 0.0); + let mut st = base_strokes(0); + st.push(red.clone()); + assert_eq!(seg(&st).matte[i], 0, "background over the disk removes it"); + st.push(green); + let restored = seg(&st); + assert_eq!(restored.matte[i], 1, "foreground over the background stroke restores it"); + assert!(iou(&restored.matte, >) > 0.95, "IoU {}", iou(&restored.matte, >)); + st.push(red); + assert_eq!(seg(&st).matte[i], 0, "background again removes it again"); +} + /// A soft-edged disk (alpha ramp over `width` pixels) over a background. fn soft_frame(c: [f64; 2], width: f64, fg: [f32; 3], bg: [f32; 3]) -> (Image, Vec) { let mut img = Image::new(W, H); @@ -265,6 +287,31 @@ fn refine_edge_recovers_a_soft_edge_and_decontaminates() { assert!(a2.iter().zip(&seg.matte).all(|(x, y)| (*x - *y as f32).abs() < 1e-6)); } +/// Refine Edge strokes correct each other the same way: refine over an earlier erase restores +/// the band there. +#[test] +fn later_refine_strokes_override_earlier_erases() { + let c = [100.0, 75.0]; + let (img, _) = soft_frame(c, 10.0, [0.9, 0.25, 0.1], [0.1, 0.3, 0.9]); + let spot = [c[0] + R, c[1]]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let erase = Stroke { kind: StrokeKind::RefineErase, frame: 0, radius: 6.0, points: vec![spot] }; + let refine = Stroke { kind: StrokeKind::Refine, ..erase.clone() }; + let band = |st: &[Stroke]| segment(&img, &st.iter().collect::>(), None, &SegOpts::default(), 9.0).refine[i]; + let mut st = vec![ + line(StrokeKind::Fg, 0, [c[0] - 15.0, c[1]], [c[0] + 15.0, c[1]], 5.0), + line(StrokeKind::Bg, 0, [c[0] - 60.0, c[1] - 55.0], [c[0] + 60.0, c[1] - 55.0], 5.0), + ring(0, c, R, 9.0), + ]; + assert_eq!(band(&st), 1); + st.push(erase.clone()); + assert_eq!(band(&st), 0, "erase removes the band"); + st.push(refine); + assert_eq!(band(&st), 1, "refine over the erase restores it"); + st.push(erase); + assert_eq!(band(&st), 0, "erase again removes it again"); +} + #[test] fn reduce_chatter_and_motion_blur_use_the_neighbours() { let (w, h) = (40usize, 20usize); @@ -373,9 +420,8 @@ impl effectcraft_segment::MaskModel for FakeModel { } /// A grey bar on a two-tone background whose halves match the bar's halves: colour alone can't -/// find its edge, a model can. -#[test] -fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { +/// find its edge, a model can. The frame, its truth and the bar's rectangle. +fn bar_frame() -> (Image, Vec, [usize; 4]) { let (w, h) = (160usize, 100usize); let rect = [40, 30, 120, 70]; let mut img = Image::new(w as u32, h as u32); @@ -390,6 +436,12 @@ fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { gt[y * w + x] = inside as u8; } } + (img, gt, rect) +} + +#[test] +fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { + let (img, gt, rect) = bar_frame(); let st = [line(StrokeKind::Fg, 0, [50.0, 50.0], [110.0, 50.0], 4.0)]; let refs: Vec<&Stroke> = st.iter().collect(); let opts = SegOpts::default(); @@ -450,18 +502,80 @@ fn mobilesam_segments_and_propagates_the_disk() { fn box_stroke_is_the_model_box_prompt() { let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]] }; let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]] }; - let p = stroke_prompt(&[&fg, &bx], 0.5); + let p = stroke_prompt(&[&fg, &bx], &[], 0.5, 0, 0); assert_eq!(p.bbox, Some([10.0, 5.0, 40.0, 30.0])); assert_eq!(p.points, vec![([25.0, 20.0], true)]); // Degenerate or non-finite boxes are no prompt. for pts in [vec![[20.0, 10.0]], vec![[20.0, 10.0], [20.0, 50.0]], vec![[f64::NAN, 10.0], [80.0, 60.0]]] { let b = Stroke { points: pts, ..bx.clone() }; - assert_eq!(stroke_prompt(&[&fg, &b], 1.0).bbox, None); + assert_eq!(stroke_prompt(&[&fg, &b], &[], 1.0, 0, 0).bbox, None); } assert!(!StrokeKind::Box.is_segmentation()); assert!(rasterize(&[&bx], &[StrokeKind::Fg, StrokeKind::Bg], 1.0, 100, 80).iter().all(|v| !v)); - let mut q = stroke_prompt(&[&fg, &bx], 1.0); + let mut q = stroke_prompt(&[&fg, &bx], &[], 1.0, 0, 0); let soft: Vec = (0..100 * 80).map(|i| ((i % 100) < 10) as u8 as f32).collect(); - assert!(add_prior_prompt(&mut q, &soft, 100, 80)); + assert!(add_prior_prompt(&mut q, &soft, &[], 100, 80)); assert_eq!(q.bbox, Some([20.0, 10.0, 80.0, 60.0])); } + +/// A model is never prompted against the latest correction: stroke points that a later stroke +/// of the other kind painted over are dropped, and the propagated matte's points and mask leave +/// out what this frame's background strokes removed. +#[test] +fn model_prompts_follow_the_latest_stroke() { + let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 4.0, points: vec![[50.0, 40.0]] }; + let red = Stroke { kind: StrokeKind::Bg, ..green.clone() }; + let (w, h) = (100, 80); + let prompt = |st: &[&Stroke]| stroke_prompt(st, &resolve(st, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h), 1.0, w, h).points; + assert_eq!(prompt(&[&green, &red]), vec![([50.0, 40.0], false)]); + assert_eq!(prompt(&[&green, &red, &green]), vec![([50.0, 40.0], true); 2]); + let soft: Vec = (0..w * h).map(|i| ((i % w) < 50) as u8 as f32).collect(); + let erase = Stroke { kind: StrokeKind::Bg, frame: 0, radius: 15.0, points: vec![[25.0, 40.0]] }; + let hard = resolve(&[&erase], StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + let mut q = Prompt::default(); + assert!(add_prior_prompt(&mut q, &soft, &hard, w, h)); + assert!(!q.points.is_empty()); + assert!(q.points.iter().all(|(p, fg)| *fg && owner(&hard, w, h, *p) != Some(HARD_BG)), "{:?}", q.points); + let mask = q.mask.unwrap(); + assert!(hard.iter().zip(&mask).all(|(hd, m)| *hd != HARD_BG || *m == 0.0)); +} + +/// A stand-in model that counts its calls; its foreground is a fixed rectangle. +struct CountingModel([usize; 4], std::sync::atomic::AtomicUsize); + +impl effectcraft_segment::MaskModel for CountingModel { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::MOBILE_SAM + } + fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &Prompt) -> Result, String> { + self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let [x0, y0, x1, y1] = self.0; + Ok((0..w * h).map(|i| ((i % w) >= x0 && (i % w) < x1 && (i / w) >= y0 && (i / w) < y1) as u8 as f32 * 0.98 + 0.01).collect()) + } +} + +/// A small background correction in the middle of a large foreground dot removes the only point +/// sampled along the dot, but most of the dot is still foreground: the model is still prompted +/// with it, from a point inside what is left. A dot painted over completely prompts nothing. +#[test] +fn a_correction_keeps_the_model_prompted_with_what_is_left() { + let (img, gt, rect) = bar_frame(); + let (w, h) = (img.width as usize, img.height as usize); + let dot = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[80.0, 50.0]] }; + let tap = Stroke { kind: StrokeKind::Bg, radius: 2.0, ..dot.clone() }; + let refs = [&dot, &tap]; + let model = CountingModel(rect, Default::default()); + let seg = segment_with(&img, &refs, None, &SegOpts::default(), 0.0, Some(&model)); + assert_eq!(model.1.load(std::sync::atomic::Ordering::Relaxed), 1, "the model was prompted"); + assert!(iou(&seg.matte, >) > 0.95, "IoU {}", iou(&seg.matte, >)); + let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + let fg: Vec<[f32; 2]> = stroke_prompt(&refs, &hard, 1.0, w, h).points.into_iter().filter(|p| p.1).map(|p| p.0).collect(); + let [q] = fg.as_slice() else { panic!("one foreground point, got {fg:?}") }; + let r = (q[0] - 80.0).hypot(q[1] - 50.0); + assert!(r > 5.0 && r < 13.0, "{q:?} sits inside what is left of the dot"); + assert_eq!(hard[q[1] as usize * w + q[0] as usize], HARD_FG); + let cover = Stroke { radius: 16.0, ..tap }; + let refs = [&dot, &cover]; + let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + assert!(stroke_prompt(&refs, &hard, 1.0, w, h).points.iter().all(|p| !p.1)); +} diff --git a/crates/track/src/stabilize.rs b/crates/track/src/stabilize.rs index 9769fcbd0..90cab6aab 100644 --- a/crates/track/src/stabilize.rs +++ b/crates/track/src/stabilize.rs @@ -156,10 +156,27 @@ impl Analyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { + self.push_impl(frame, !self.opts.detailed); + } + + fn push_impl(&mut self, frame: &Frame, share_pyramid: bool) { + let share_pyramid = share_pyramid && !self.opts.detailed; let (max_side, max_feat) = if self.opts.detailed { (960, 700) } else { (480, 350) }; let factor = analysis_factor(frame.img.width, frame.img.height, max_side); - let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 3); - self.trajectories.push(frame); + let mut pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, if share_pyramid { 4 } else { 3 }); + if share_pyramid { + self.trajectories.push_pyramid(self.prev.as_ref(), &pyr); + } else { + // Detailed analysis uses different downsampling factors for motion and trajectories. + self.trajectories.push(frame); + } + // Motion tracking historically used only three extra levels. Temporarily remove + // the trajectory tracker's fourth level without cloning any planes or gradients. + let extra = if share_pyramid && pyr.levels.len() > 4 { pyr.levels.pop().zip(pyr.grads.pop()) } else { None }; + if share_pyramid && let Some(prev) = &mut self.prev { + prev.levels.truncate(4); + prev.grads.truncate(4); + } let mut fm = FrameMotion::default(); if let Some(prev) = &self.prev && prev.width() == pyr.width() @@ -195,6 +212,10 @@ impl Analyzer { fm.points = round_pts(&inl, 48); } } + if let Some((level, grad)) = extra { + pyr.levels.push(level); + pyr.grads.push(grad); + } self.frames.push(fm); self.prev = Some(pyr); } @@ -833,6 +854,39 @@ mod tests { tot / cnt.max(1) as f64 } + #[test] + fn shared_pyramid_preserves_motion_and_trajectory_bits() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (size, detailed) in [([321.0, 181.0], false), ([997.0, 565.0], false), ([97.0, 63.0], false), ([1281.0, 721.0], true)] { + let mut scene = Scene::new(12); + scene.size = size; + let mut shared = Analyzer::new(size, AnalyzeOpts { detailed }); + let mut separate = Analyzer::new(size, AnalyzeOpts { detailed }); + for k in 0..12 { + // Also cover partial downsample blocks, an offset/size change and the + // resulting reset of active features halfway through the clip. + if k == 6 { + scene.size = [size[0] + 7.0, size[1] + 5.0]; + } + let img = scene.frame(k); + let frame = Frame { img: &img, offset: if k < 6 { [2.25, -3.5] } else { [-1.75, 4.5] } }; + shared.push(&frame); + separate.push_impl(&frame, false); + } + let a = shared.finish(0.123456789012345, 1.0 / 24.0); + let b = separate.finish(0.123456789012345, 1.0 / 24.0); + assert!(!a.tracks.is_empty(), "exercise live trajectories: {size:?}, detailed={detailed}"); + assert!(a.frames.iter().any(|f| f.features > 0)); + assert_eq!(a.to_json(), b.to_json(), "{size:?}, detailed={detailed}"); + for (a, b) in a.frames.iter().zip(&b.frames) { + for (a, b) in a.t.iter().chain(&a.s).chain(&a.h).zip(b.t.iter().chain(&b.s).chain(&b.h)) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } + } + }); + } + #[test] fn subspace_warp_beats_perspective_on_parallax() { let n = 48; diff --git a/crates/track/src/tests.rs b/crates/track/src/tests.rs index 886478d1e..c019996f5 100644 --- a/crates/track/src/tests.rs +++ b/crates/track/src/tests.rs @@ -93,6 +93,55 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt() } +fn bits(v: &Option<([f64; 2], [f64; 2])>) -> Option<[u64; 4]> { + v.map(|(c, a)| [c[0].to_bits(), c[1].to_bits(), a[0].to_bits(), a[1].to_bits()]) +} + +#[test] +fn scorer_matches_ncc_for_integer_offsets() { + let pose = Pose { c: [60.0, 50.0], rot: 0.0, scale: 1.0 }; + let img = frame(120, 100, pose, 20.0, 0.02, 1, None); + let crop = Crop::new(&Frame::new(&img), &TrackOptions::default(), [60.0, 50.0], [40.0, 30.0], 1); + for (lvl, c, a) in [(0, [30.3, 25.9], [0.97, 0.21]), (0, [12.0, 9.5], [1.2, -0.3]), (1, [20.4, 14.2], [0.8, 0.0]), (0, [30.3, 25.9], [1.0, 0.0])] { + let plane = &crop.levels[lvl]; + let r = [7, 5]; + let patch = Patch::sample(plane, c, a, r); + let sc = Scorer::new(plane, &patch, [c[0] + 0.3, c[1] - 0.45], a); + for j in -9..=9 { + for i in -9..=9 { + let q = [c[0] + 0.3 + i as f64, c[1] - 0.45 + j as f64]; + assert_eq!(sc.at(i, j).to_bits(), ncc(plane, &patch, q, a).to_bits(), "level {lvl} {a:?} ({i}, {j})"); + } + } + } +} + +#[test] +fn lk_refine_with_a_gradient_window_equals_the_full_gradients() { + let pose = Pose { c: [100.0, 90.0], rot: 5.0, scale: 1.05 }; + let (img0, img1) = (frame(200, 180, pose, 30.0, 0.02, 1, None), frame(200, 180, Pose { c: [103.2, 88.1], rot: 6.5, scale: 1.08 }, 30.0, 0.02, 2, None)); + let (opts, r) = (TrackOptions::default(), [24, 24]); + let (c0, a0) = ([100.0, 90.0], [1.0, 0.0]); + let tpl = Crop::new(&Frame::new(&img0), &opts, c0, [60.0, 60.0], 0); + let crop = Crop::new(&Frame::new(&img1), &opts, c0, [60.0, 60.0], 0); + let plane = &crop.levels[0]; + let patch = Patch::sample(&tpl.levels[0], tpl.to_level(c0, 0), a0, r); + let mid = crop.to_level(c0, 0); + // Starts within the window, and far enough off that the iterations leave it (even near the edge). + for (d, edge) in [([0.0, 0.0], false), ([1.3, -0.8], false), ([4.0, 5.0], false), ([9.0, -9.0], false), ([0.0, 0.0], true)] { + let start = if edge { [4.0, 4.0] } else { [mid[0] + d[0], mid[1] + d[1]] }; + for shape in [true, false] { + let (mut full, mut win) = (Grad::full(plane), Grad::around(plane, start, a0, r)); + let x = lk_refine(&crop, &mut full, &patch, start, a0, shape); + let y = lk_refine(&crop, &mut win, &patch, start, a0, shape); + assert_eq!(bits(&x), bits(&y), "{d:?} edge {edge} shape {shape}"); + // A window that misses the taps altogether is replaced by the full gradients. + let mut off = Grad::around(plane, [start[0] + 15.0, start[1] + 15.0], a0, r); + assert_eq!(bits(&x), bits(&lk_refine(&crop, &mut off, &patch, start, a0, shape)), "{d:?} edge {edge} shape {shape}: missed window"); + } + } +} + #[test] fn recovers_translation_path_subpixel() { let path = |f: usize| { @@ -114,6 +163,27 @@ fn recovers_translation_path_subpixel() { assert!(worst < 0.25, "worst error {worst} px"); } +#[test] +fn recovers_after_sudden_jumps_the_prediction_misses() { + // Alternating large steps defeat the velocity prediction, so the fine stage must keep its + // wider neighbourhood; smooth frames in between use the narrow one. + let path = |f: usize| { + let t = f as f64; + let jump = [0.0, 7.0, -5.0, 9.0, -8.0, 6.0, -7.0, 8.0]; + let j = jump[f % jump.len()]; + Pose { c: [120.0 + 1.5 * t + j, 100.0 + 1.1 * t - 0.6 * j], rot: 0.0, scale: 1.0 } + }; + let f0 = frame(360, 260, path(0), 40.0, 0.02, 0, None); + let mut tr = Tracker::new(TrackOptions::default(), &[spec(path(0).c, 31.0, 91.0)], &Frame::new(&f0)); + for f in 1..24 { + let img = frame(360, 260, path(f), 40.0, 0.02, f as u32, None); + let r = tr.step(&Frame::new(&img))[0]; + let e = dist(r.center, path(f).c); + assert!(e < 0.3, "frame {f}: error {e} px"); + assert_eq!(r.status, Status::Tracked); + } +} + #[test] fn layer_offset_and_channels() { let pose = |f: usize| Pose { c: [120.0 + 3.3 * f as f64, 100.0 - 1.7 * f as f64], rot: 0.0, scale: 1.0 }; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index d41e0a9d0..a3263a677 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -131,6 +131,9 @@ impl EffectcraftApp { self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); + // On macOS the app quits through `-[NSApplication terminate:]` instead of closing the + // window while AppKit's run loop is running (#341). + craft_quit::on_close_request(ctx); return; } // Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 912a196f8..d40bc69ff 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -40,11 +40,16 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + starting: false, + starting_since: now, + first_cached: None, shown_at: Default::default(), }; if !self.playback.caching { if plan.video { - self.playback.audio_held = self.audio_plays(); + let audible = self.audio_plays(); + self.playback.audio_held = audible; + self.playback.starting = audible; } else { self.start_audio(fr.tick_of(plan.first)); } @@ -104,6 +109,7 @@ impl EffectcraftApp { let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; self.playback.caching = false; + self.playback.starting = false; if !was { return; } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 1fdf0aad1..d351ca411 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the +/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever: +/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does. +const STARTUP_WAIT: f64 = 2.0; + +/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame +/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from +/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size +/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled +/// one does: its sound starts once the frames are cached. +const STARTUP_LIMIT: f64 = 10.0; + +/// How many frames from the first a preview with sound waits for before it starts (#947): the +/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets +/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at +/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer +/// always requests. Waiting for a frame that is never requested, or that can't stay cached with +/// the others, would hold the first frame for ever. +fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 { + let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64; + ahead.min(window).min(fit).max(1) +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -53,6 +76,9 @@ impl EffectcraftApp { return; } let fit = self.frames_that_fit(&c, scale); + if self.hold_first_frame(&pass, fit) { + return; + } self.start_held_audio(&pass, fit); if self.advance_audio_clock(&pass, step) { return; @@ -179,8 +205,54 @@ impl EffectcraftApp { } } - /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the - /// prefetch window when the silent preview keeps real time, else the rest of the preview. + /// Starting a preview with sound: hold the first frame until the frames ahead are cached, then + /// start the sound from that same frame (see `Playback::starting`). Without an output device + /// the picture plays on its own clock from it. How many frames ahead: only ones + /// `prefetch_playback` requests and that fit in the RAM preview at the size they really render + /// (an Extended Viewer frame is larger than the comp), so the wait always ends. Returns true + /// while it holds the first frame. + fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool { + let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; + if !self.playback.starting { + return false; + } + let Some(p) = plan else { return false }; + self.playback.start_wall = now; + self.playback.start_frame = cur; + // `prefetch_playback` requests every uncached frame of offsets 0..window from here. + let window = (ahead * 3).min(fit - 1); + let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| { + let [w, h] = img.size(); + startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) + }); + if need.is_some() && self.playback.first_cached.is_none() { + self.playback.first_cached = Some(now); + } + // Don't wait for frames the RAM preview can't hold (not even one frame fits), for the + // frames ahead longer than STARTUP_WAIT once the first is in, nor longer than + // STARTUP_LIMIT in all (a frame that never gets cached). + let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale) + || self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT) + || now - self.playback.starting_since >= STARTUP_LIMIT; + if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { + self.playback.waiting = true; + ctx.request_repaint(); + return true; + } + self.playback.starting = false; + self.playback.waiting = false; + // Not on a frame that isn't cached: the stall below would stop the sound at once. The + // preview goes on as a stalled one does, and the sound starts once frames are cached. + if need.is_some() { + self.playback.audio_held = false; + self.start_audio(fr.tick_of(cur)); + } + false + } + + /// Held sound starts once the frames ahead are cached again after a stall (see + /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else + /// the rest of the preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { @@ -334,7 +406,8 @@ impl EffectcraftApp { } let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb); let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n); - let slots = (self.frames_parallelism() / 2).max(1); + // One thread each (`Frames::request_background`), so every frame thread takes one. + let slots = self.frames_parallelism().max(1); let mut queued = self.frames.inflight(); let scale = series.scale as f64 / 1000.0; let mut pending = false; @@ -347,7 +420,7 @@ impl EffectcraftApp { if queued >= slots { break; } - self.request_frame(ItemId(series.comp), f, scale); + self.request_frame_background(ItemId(series.comp), f, scale); queued += 1; } if pending { @@ -365,9 +438,13 @@ impl EffectcraftApp { } fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 { + ((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1) + } + + /// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger). + fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 { let side = |n: u32| (n as f64 * scale).ceil().max(1.0); - let frame = side(comp.width) * side(comp.height) * 4.0; - ((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1) + side(comp.width) * side(comp.height) * 4.0 } fn frames_parallelism(&self) -> usize { @@ -379,3 +456,28 @@ impl EffectcraftApp { self.frames.parallelism() } } + +#[cfg(test)] +mod startup_tests { + use super::startup_frames; + + /// The frames a preview with sound waits for are always requested and fit together (#947). + #[test] + fn startup_waits_only_for_frames_it_requests_and_can_keep() { + // A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch + // window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for. + let frame = 64 * 36 * 4; + let fit = 4; + let window = (32i64 * 3).min(fit - 1); + assert_eq!(startup_frames(32, window, 46_080, frame), 3); + // A roomy budget: the prefetch's `ahead` frames. + assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8); + // Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized + // frames but only 2 real ones (90 %): wait for 2, not 4. + assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2); + // Nothing fits, or no window: still the current frame, never 0. + assert_eq!(startup_frames(8, 0, 0, frame), 1); + // A zero frame size counts as 1 byte: no division by zero. + assert_eq!(startup_frames(8, 24, 10, 0), 8); + } +} diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index 3c8632cea..c878fafa4 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -126,12 +126,18 @@ impl EffectcraftApp { /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, false); + self.request_frame_with(comp, frame, scale, frames::Frames::request); } /// Request the frame on screen: rendered before any queued prefetch. pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, true); + self.request_frame_with(comp, frame, scale, frames::Frames::request_urgent); + } + + /// Request a frame nobody waits for (Cache Frames When Idle): rendered on one thread + /// (`Frames::request_background`). + pub fn request_frame_background(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, frames::Frames::request_background); } /// Render options of viewer frames of `comp` at `scale`. @@ -152,15 +158,17 @@ impl EffectcraftApp { } } - fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { + fn request_frame_with( + &self, + comp: ItemId, + frame: i64, + scale: f64, + request: fn(&frames::Frames, &frames::RenderSource, FrameKey, ItemId, Tick, RenderOpts), + ) { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); let opts = self.frame_opts(comp, scale); - if urgent { - self.frames.request_urgent(&self.render_source(), key, comp, t, opts); - } else { - self.frames.request(&self.render_source(), key, comp, t, opts); - } + request(&self.frames, &self.render_source(), key, comp, t, opts); } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e535cc723..f67591bee 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.inspect`: UI state + summary; `ui.elements {prefix?}`: registered widgets (id, label, rect) //! - `ui.set {tool?, workspace?, theme?, focused?, viewer?: {zoom, res, pan, grid, …}, timeline?: {pps, start, graphEditor, showModes}}` //! - `ui.panel.show {panel}` / `ui.panel.close {panel}` -//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` +//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy,unit?,source?,pinch?}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` //! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point @@ -500,8 +500,9 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } -/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's -/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. +/// Queues a move to the target point, a wheel event of `dx`, `dy` (`unit`: points or lines) with +/// the request's modifiers, a pinch's zoom factor, and a released Extra1 button. Replies +/// [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -509,14 +510,23 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + // Without a `source`, a scroll in lines is a mouse wheel and one in points comes from an + // unnamed device, as on Windows, Linux and the web. + let source = match p.get("source").and_then(Value::as_str) { + Some("trackpad") => egui::MouseWheelSource::Trackpad, + Some("wheel") => egui::MouseWheelSource::Wheel, + _ if line => egui::MouseWheelSource::Wheel, + _ => egui::MouseWheelSource::Unknown, + }; app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(dx, dy), - modifiers: modifiers(p), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - }); + if dx != 0.0 || dy != 0.0 { + app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, source }); + } + if let Some(pinch) = p.get("pinch").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0) { + app.synthetic.push(egui::Event::Zoom(pinch as f32)); + } app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(-100.0, -100.0), button: egui::PointerButton::Extra1, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 054aa3598..8e8fd5eca 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -531,7 +531,8 @@ pub enum DocAction { const TAB_PADDING: f32 = 24.0; /// Width of the » button that lists the comp tabs with no room in the strip. const DOC_OVERFLOW: f32 = 22.0; -/// Room for the × after the shown comp's name. +/// Room for the × after a comp's name. Every comp tab keeps it, so a tab keeps its width when it +/// is shown or hovered. const DOC_CLOSE: f32 = 16.0; /// A comp tab's text: the label swatch, then the name, as the panel tabs with a swatch show it. @@ -547,24 +548,46 @@ fn text_width(ctx: &egui::Context, job: egui::text::LayoutJob) -> f32 { ctx.fonts_mut(|f| f.layout_job(job)).size().x } -/// Whether comp tab `doc` carries the ×: the comp the Timeline shows, while it is the group's -/// shown panel. -fn has_close(doc: &DocTab, selected: bool) -> bool { - selected && doc.active +/// The width each comp tab asks for in the Timeline group's tab row. +pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], font: &egui::FontId) -> Vec { + docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + DOC_CLOSE).collect() } -/// The width each comp tab asks for in the Timeline group's tab row. -pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], selected: bool, font: &egui::FontId) -> Vec { - docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + if has_close(d, selected) { DOC_CLOSE } else { 0.0 }).collect() +/// The × of comp tab `doc` at `rect`, clipped to `clip`; true when clicked. `shown` is the comp +/// the Timeline shows while it is the group's shown panel. Automation: +/// `panel.tab.Timeline..close`, and `panel.tab.Timeline.close` for the shown comp's. +fn doc_close( + ui: &mut egui::Ui, + rect: Rect, + clip: Rect, + doc: &DocTab, + shown: bool, + style: &craft_ui::docking::DockStyle, + reg: &mut crate::automation::Registry, +) -> bool { + let timeline = PanelKind::Timeline.id(); + let hit = ui.interact(rect.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); + let cc = if hit.hovered() { style.text } else { style.inactive_text }; + let k = 3.5; + let painter = ui.painter().with_clip_rect(clip); + painter.line_segment([rect.center() + vec2(-k, -k), rect.center() + vec2(k, k)], Stroke::new(1.2, cc)); + painter.line_segment([rect.center() + vec2(-k, k), rect.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + reg.add_response(&format!("panel.tab.{timeline}.{}.close", doc.id), &hit, "Close"); + if shown { + reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); + } + hit.clicked() } /// Draw the Timeline's comp tabs into `strip`, the part of the Timeline group's tab row after its -/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, and × on the -/// shown comp while the Timeline is the group's shown panel (`selected`). Tabs that do not fit go -/// to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and Shift+. step -/// through every open comp. Automation: `panel.tab.Timeline.`, `panel.tab.Timeline.close` -/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` -/// for its items. +/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, then a × +/// that closes the comp's Timeline: always on the shown comp while the Timeline is the group's +/// shown panel (`selected`), on the other comp tabs while the pointer is over them. Tabs that do +/// not fit go to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and +/// Shift+. step through every open comp. Automation: `panel.tab.Timeline.`, the ×s (see +/// [`doc_close`]), `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` for its +/// items. pub fn doc_tabs( ui: &mut egui::Ui, strip: Rect, @@ -575,7 +598,7 @@ pub fn doc_tabs( ) -> Vec { let mut out = Vec::new(); let timeline = PanelKind::Timeline.id(); - let widths = doc_widths(ui.ctx(), docs, selected, &style.font); + let widths = doc_widths(ui.ctx(), docs, &style.font); let active = docs.iter().position(|d| d.active).unwrap_or(0); let fit = craft_ui::tabs::fit_whole(&widths, active, strip.width(), DOC_OVERFLOW); let clip = strip.intersect(ui.clip_rect()); @@ -587,10 +610,9 @@ pub fn doc_tabs( x += width; let on = selected && doc.active; let color = if on { style.text } else { style.inactive_text }; - let close = has_close(doc, selected); let mut job = doc_job(doc, &style.font, color); job.wrap = egui::text::TextWrapping { - max_width: (tab.width() - TAB_PADDING - if close { DOC_CLOSE } else { 0.0 }).max(0.0), + max_width: (tab.width() - TAB_PADDING - DOC_CLOSE).max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}'), @@ -608,17 +630,10 @@ pub fn doc_tabs( ); let resp = if galley.elided { resp.on_hover_text(&doc.title) } else { resp }; reg.add_response(&format!("panel.tab.{timeline}.{}", doc.id), &resp, &doc.title); - if close { + // After the tab, so the × takes the clicks over it. + if on || ui.rect_contains_pointer(tab.intersect(clip)) { let cr = Rect::from_center_size(pos2(label_end + DOC_CLOSE / 2.0, mid), vec2(10.0, 10.0)); - let hit = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); - hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); - let cc = if hit.hovered() { style.text } else { style.inactive_text }; - let k = 3.5; - let painter = ui.painter().with_clip_rect(tab.intersect(clip)); - painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); - painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); - reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); - if hit.clicked() { + if doc_close(ui, cr, tab.intersect(clip), doc, on, style, reg) { out.push(DocAction::Close(doc.id)); } } @@ -654,7 +669,8 @@ pub fn doc_tabs( out } -/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked). +/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked): a +/// closed padlock while locked, an open one while unlocked. /// Returns true when clicked. Automation: `panel.tab..lock`. pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool, t: &Tokens, reg: &mut crate::automation::Registry) -> bool { let resp = ui.interact(rect, egui::Id::unique(("tab-lock", panel.id())), Sense::click()); @@ -665,7 +681,7 @@ pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool } else { t.text_faint }; - icons::paint(ui.painter(), rect.shrink(2.0), Icon::Lock, color); + icons::paint(ui.painter(), rect.shrink(2.0), if locked { Icon::Lock } else { Icon::LockOpen }, color); reg.add_response(&format!("panel.tab.{}.lock", panel.id()), &resp, if locked { "Unlock" } else { "Lock" }); crate::widgets::focus_outline(ui, &resp, t); resp.clicked() diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 30710392a..d17af3b46 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -86,6 +86,13 @@ fn panel_param(value: &Value) -> Result { } } +fn check_raw_action(action: &Action) -> Result<(), String> { + if matches!(action, Action::Open { .. } | Action::OpenAt { .. }) { + return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); + } + Ok(()) +} + /// The docking commands: `window.dockAction {action}`, craft-ui's `window.panel.*` commands, /// `ui.dock` and `ui.dock.reset`. `None` for any other id. pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -93,9 +100,7 @@ pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, par "window.dockAction" => Some((|| { let value = params.get("action").cloned().ok_or("missing docking action")?; let action: Action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; - if matches!(&action, Action::Open { .. } | Action::OpenAt { .. }) { - return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); - } + check_raw_action(&action)?; app.apply_dock(action)?; Ok(Value::Null) })()), @@ -153,6 +158,9 @@ impl EffectcraftApp { /// Run a craft-ui docking command on the current workspace, all or nothing. Named /// workspaces and Reset are EffectCraft's own (`window.workspace`, `window.resetWorkspace`). pub(crate) fn run_dock_command(&mut self, ctx: &egui::Context, command: DockCommand) -> Result<(), String> { + if let DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) = &command { + check_raw_action(action)?; + } if command.is_app_owned() { if matches!(command, DockCommand::ResetWorkspace {}) { let name = self.ui.workspace.clone(); @@ -835,11 +843,11 @@ impl DockContent for EffectContent<'_> { } } - fn group_extra_tabs_width(&self, panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 { + fn group_extra_tabs_width(&self, panels: &[PanelKind], _active: &PanelKind, _floating: bool) -> f32 { if !self.has_doc_tabs(panels) { return 0.0; } - dock::doc_widths(&self.ctx, &self.docs, *active == PanelKind::Timeline, &self.style.font).iter().sum() + dock::doc_widths(&self.ctx, &self.docs, &self.style.font).iter().sum() } /// The Timeline's comp tabs, after its group's panel tabs whichever panel is shown. diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a7023a494..883cbf172 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1,7 +1,8 @@ //! Background frame rendering and the RAM preview cache. //! //! Frames render on a dedicated thread pool from immutable `Arc` snapshots, so the UI -//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's +//! never waits for the compositor (a playing preview's frames and idle caching on one thread +//! each, so the frames started first are done first). Results land in a memory-budgeted cache keyed by the comp's //! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render //! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames //! of every comp it doesn't touch, and undo finds the frames of the state it returns to. @@ -31,7 +32,7 @@ use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Pro use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; -use effectcraft_gpu::{DisplayFrame, Gpu}; +use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; use rayon::prelude::*; /// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of. @@ -317,23 +318,6 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, project, |_, c| c) } -/// Whether two versions of a comp draw the same pixels. Markers only count when an expression -/// could read them (`markers_count`). -fn same_pixels(a: &Comp, b: &Comp, markers_count: bool) -> bool { - if markers_count { a.same_pixels(b) } else { a.same_pixels_ignoring_markers(b) } -} - -/// Whether a layer anywhere in the project has an expression, which can read markers. -fn project_has_expressions(project: &Project) -> bool { - fn has_expression(g: &PropGroup) -> bool { - g.children.iter().any(|c| match c { - Node::Prop(p) => p.expr.is_some(), - Node::Group(sub) => has_expression(sub), - }) - } - project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props)))) -} - /// [`comp_content`] with each comp's version selected for both its sources and its address. /// Sources removed by undo are read from `base`. fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc) -> &'a Arc) -> u64 { @@ -473,6 +457,10 @@ pub struct Frames { #[cfg(not(target_arch = "wasm32"))] /// None if the OS refused the threads: jobs then go to rayon's global pool. pool: Option, + #[cfg(not(target_arch = "wasm32"))] + /// One thread each, for a playing preview's frames and idle caching (see [`Queue::idle_lanes`]). Empty if + /// the OS refused the threads: those frames then render on `pool` too. + lanes: Vec, ctx: Option, /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, @@ -501,10 +489,25 @@ impl Default for Frames { fn default() -> Self { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..threads) + .map_while(|i| { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .thread_name(move |_| format!("ec-preview-{i}")) + .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) + .build() + .ok() + }) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + #[cfg(target_arch = "wasm32")] + let queue = Queue::default(); Frames { cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), - queue: Arc::new(Mutex::new(Queue::default())), + queue: Arc::new(Mutex::new(queue)), #[cfg(not(target_arch = "wasm32"))] // A panicking job would abort the whole process (rayon's default for `spawn`): log it // and lose only that frame (the panic hook has logged the message). @@ -514,6 +517,8 @@ impl Default for Frames { .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() .ok(), + #[cfg(not(target_arch = "wasm32"))] + lanes, ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), @@ -762,12 +767,12 @@ impl Frames { fn identity(&self, project: &Arc, comp: ItemId) -> Identity { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; - // Markers can only change pixels through expressions, which read them. - let markers_count = project_has_expressions(project); + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, _ => c, }) == *base_id { @@ -779,7 +784,7 @@ impl Frames { if let Some((base_id, base)) = bases.get(&comp.0) && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) && comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, _ => c, }) == *base_id && let Some((s, e)) = new.changed_span(old) @@ -928,26 +933,77 @@ impl Frames { c.playhead = playhead; } let Ok(mut q) = self.queue.lock() else { return }; + // A preview starting or stopping moves the queued prefetch frames between the lanes and + // the pool (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; - let Some(p) = playhead else { return }; - let Ok(mut inf) = self.inflight.lock() else { return }; - q.jobs.retain(|j| { - let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); - if gone { - inf.remove(&j.key); + if let Some(p) = playhead + && let Ok(mut inf) = self.inflight.lock() + { + q.jobs.retain(|j| { + let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); + if gone { + inf.remove(&j.key); + } + !gone + }); + } + #[cfg(not(target_arch = "wasm32"))] + if moved { + self.start_lanes(&mut q, usize::MAX); + let pool_jobs = q.jobs.iter().filter(|j| !q.on_lane(j)).count().min(self.parallelism()); + drop(q); + for _ in 0..pool_jobs { + self.start_pool(); } - !gone - }); + } + } + + /// Start up to `n` idle lanes on the queued jobs (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + fn start_lanes(&self, q: &mut Queue, n: usize) { + if self.remote.is_some() { + return; + } + let jobs = q.jobs.iter().filter(|j| q.on_lane(j)).count(); + for _ in 0..n.min(jobs) { + let Some(lane) = q.idle_lanes.pop() else { break }; + let Some(pool) = self.lanes.get(lane) else { break }; + let w = self.worker(); + pool.spawn(move || w.drain(Some(lane))); + } + } + + /// Have a pool thread render the queued jobs that are the pool's. + #[cfg(not(target_arch = "wasm32"))] + fn start_pool(&self) { + if self.remote.is_some() { + return; + } + let w = self.worker(); + let job = move || w.drain(None); + match &self.pool { + Some(pool) => pool.spawn(job), + None => rayon::spawn(job), + } } /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { - self.request_with(src, key, comp, t, opts, false); + self.request_with(src, key, comp, t, opts, Want::Prefetch); + } + + /// Queue a frame nobody waits for (Cache Frames When Idle): as [`Frames::request`], but it + /// renders on a lane, one thread, and leaves the pool to the viewer's frame (see + /// [`Queue::idle_lanes`]). + pub fn request_background(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, Want::Background); } /// Queue the frame the viewer is showing: it jumps ahead of every prefetch job. pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { - self.request_with(src, key, comp, t, opts, true); + self.request_with(src, key, comp, t, opts, Want::Viewer); } /// An urgent (viewer) frame is queued or rendering. @@ -955,7 +1011,8 @@ impl Frames { self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false) } - fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { + fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, want: Want) { + let urgent = want == Want::Viewer; // The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at // another zoom must not evict the active viewer's frames. let cached = self @@ -1007,22 +1064,32 @@ impl Frames { } } if !inf.insert(key) { + drop(inf); + drop(q); + // Queued for a lane and now the viewer's frame: it is the pool's. + #[cfg(not(target_arch = "wasm32"))] + if urgent { + self.start_pool(); + } return; } q.seq += 1; let seq = q.seq; - q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq, cancel: Arc::default() }); + let job = Job { key, src: src.clone(), comp, t, opts, urgent, background: want == Want::Background, seq, cancel: Arc::default() }; + // A lane's frame: a busy lane takes the next job when it is done, an idle one starts now. + #[cfg(not(target_arch = "wasm32"))] + let on_lane = q.on_lane(&job); + q.jobs.push(job); + #[cfg(not(target_arch = "wasm32"))] + if on_lane { + self.start_lanes(&mut q, 1); + } drop(inf); drop(q); + // One spawn per job; each spawn renders whichever queued jobs matter most right now. #[cfg(not(target_arch = "wasm32"))] - if self.remote.is_none() { - let w = self.worker(); - // One spawn per job; each spawn renders whichever queued jobs matter most right now. - let job = move || w.drain(); - match &self.pool { - Some(pool) => pool.spawn(job), - None => rayon::spawn(job), - } + if !on_lane { + self.start_pool(); } #[cfg(target_arch = "wasm32")] if let Some(ctx) = &self.ctx { @@ -1164,7 +1231,7 @@ impl Frames { } let t0 = web_time::Instant::now(); let w = self.worker(); - while w.run_next() { + while w.run_next(None) { if t0.elapsed() >= budget { break; } @@ -1212,22 +1279,29 @@ impl Worker { /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn /// that lands inside a render returns at once instead; the render below it takes the queued /// jobs when it is done, so each thread renders one frame at a time. + /// + /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] - fn drain(&self) { + fn drain(&self, lane: Option) { if DRAINING.get() { return; } DRAINING.set(true); let _reset = Draining; - while self.run_next() {} + while self.run_next(lane) {} } - /// Render the queued job that matters most right now. `false` when the queue was empty. - fn run_next(&self) -> bool { + /// Render the queued job that matters most right now, on lane `lane` (`None`: on + /// the pool, or the UI thread). `false` when the queue had no job for it; the lane is then + /// idle again. + fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.best() else { return false }; + let Some(i) = q.next(lane.is_some()) else { + q.idle_lanes.extend(lane); + return false; + }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -1318,11 +1392,16 @@ impl Worker { /// A failed scoped attempt retries on CPU. Retired contexts cannot recover by reducing /// their budget; only a healthy device's allocation failure uses the upstream backoff. - fn gpu_attempt_failed(&self, gpu: &Gpu, e: &str) { + fn gpu_attempt_failed(&self, gpu: &Gpu, e: &FrameError) { if gpu.context().check_health().is_err() { log::warn!("the GPU context is retired ({e}); the frame renders on the CPU"); return; } + // Not memory pressure: keeping fewer GPU frames wouldn't help (#1319). + if let FrameError::Validation(_) = e { + log::warn!("the GPU rejected the frame ({e}); it renders on the CPU, the next frame tries the GPU again"); + return; + } log::warn!("the GPU ran out of memory ({e}); the frame renders on the CPU and fewer GPU frames are kept"); if let Ok(mut c) = self.cache.lock() { c.gpu_out_of_memory(); @@ -1417,6 +1496,14 @@ impl Worker { } } +/// Who asked for a frame (see [`Frames::request_with`]). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Want { + Viewer, + Prefetch, + Background, +} + struct Job { key: FrameKey, src: RenderSource, @@ -1424,6 +1511,8 @@ struct Job { t: Tick, opts: RenderOpts, urgent: bool, + /// Nobody waits for it (see [`Frames::request_background`]). + background: bool, seq: u64, /// Set when the viewer no longer waits for this frame (see [`Frames::request_urgent`]). cancel: Arc, @@ -1446,18 +1535,45 @@ struct Queue { seq: u64, /// The playing preview, if any: its prefetch renders nearest first. playhead: Option, + /// How many lanes there are, and the ones not rendering. A lane is one thread that renders + /// one frame at a time. The frames of a playing preview and the frames nobody waits for + /// (Cache Frames When Idle) render on the lanes; the pool keeps the viewer's frame and the + /// few frames prefetched around it while paused. On the pool a frame's parallel work is + /// shared: a thread that took a piece of one frame and waited inside it started rendering + /// the queued frames on top, and went back to its piece only once the queue was empty. The + /// frames a preview started first, the ones right ahead of the playhead, were done last: + /// the preview stood still on a gap while the frames further ahead turned green. Idle + /// caching left the same gaps, and held up the viewer's frame when it shared a piece. + lanes: usize, + idle_lanes: Vec, } impl Queue { + /// Whether `j` renders on a lane (else on the pool). + fn on_lane(&self, j: &Job) -> bool { + self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) + } + + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. + fn next(&self, lane: bool) -> Option { + self.best_of(|j| self.on_lane(j) == lane) + } + /// The job that matters most: urgent first (newest), then, while a preview plays, the /// prefetch frame it reaches first, then prefetch in request order. In request order alone, /// frames asked for again after an eviction (the ones right ahead of the playhead) waited /// behind every frame further ahead (#595). fn best(&self) -> Option { + self.best_of(|_| true) + } + + /// [`Queue::best`] among the jobs `of` accepts. + fn best_of(&self, of: impl Fn(&Job) -> bool) -> Option { let p = self.playhead.as_ref(); self.jobs .iter() .enumerate() + .filter(|(_, j)| of(j)) .max_by_key(|(_, j)| { let near = if j.urgent { None } else { p.and_then(|p| p.ahead(&j.key)) }; (j.urgent, near.is_some(), near.map_or(0, |d| -d), if j.urgent { j.seq as i64 } else { -(j.seq as i64) }) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 6da1dc269..49f77a6cf 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -437,7 +437,9 @@ fn a_newer_viewer_frame_drops_the_ones_still_waiting() { gpu_display: false, disk: None, }; - let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent); + let request = |k: FrameKey, urgent: bool| { + f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), if urgent { Want::Viewer } else { Want::Prefetch }) + }; let other = FrameKey { comp: 2, ..key(1, 0, 0) }; request(other, true); request(key(1, 20, 0), false); @@ -558,6 +560,131 @@ fn prefetch_renders_the_frames_the_playhead_reaches_first() { assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); } +/// A decoder that works in parallel, as the renderer does, in a comp that shows it. +#[derive(Default)] +struct ParallelClip { + started: std::sync::atomic::AtomicUsize, + /// Frames started by the time frame 1 was done. + started_at_first: std::sync::atomic::AtomicUsize, +} + +impl FootageSource for ParallelClip { + fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { + self.started.fetch_add(1, Ordering::SeqCst); + (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); + if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { + self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); + } + Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) + } +} + +/// A 10 s comp of a [`ParallelClip`]: the frame cache, the clip, the render source, the comp +/// and the key of its frame 0. +fn parallel_clip() -> (Frames, Arc, RenderSource, ItemId, FrameKey) { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(ParallelClip::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let series = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), comp: cid.0, frame: 0, scale: 1000, view: 0, opts: 0 }; + (f, footage, src, cid, series) +} + +fn wait_for_renders(f: &Frames) { + for _ in 0..8000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); +} + +/// A playing preview's frames render side by side, and each works in parallel: a render +/// waiting for its own parallel work took over pieces of the frames started after it, and +/// its frame was done only once theirs were. The frames right ahead of the playhead, started +/// first, came last: the preview stood still while the frames further ahead turned green. +/// Cache Frames When Idle left the same gaps. +#[test] +fn frames_started_first_are_done_first() { + use effectcraft_engine::time::FrameRate; + for playing in [true, false] { + let (f, footage, src, cid, series) = parallel_clip(); + let frames = 60; + if playing { + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + } + for frame in 1..=frames { + let (key, t) = (FrameKey { frame, ..series }, FrameRate::FPS_30.tick_of(frame)); + if playing { + f.request(&src, key, cid, t, RenderOpts::default()); + } else { + f.request_background(&src, key, cid, t, RenderOpts::default()); + } + if frame == 1 { + // Its parallel work has started (and idle threads have taken pieces of it). + std::thread::sleep(std::time::Duration::from_millis(3)); + } + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + let before = footage.started_at_first.load(Ordering::SeqCst); + assert!(before <= 2 * f.parallelism(), "{before} frames were started before the first one was done (playing: {playing})"); + } +} + +/// Prefetch frames queued when a preview starts or stops, or that become the viewer's +/// frame, still render: they change hands between the lanes and the pool. +#[test] +fn queued_frames_render_when_a_preview_starts_or_stops() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + let playhead = Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false }; + let request = |frames: std::ops::Range| { + for frame in frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + }; + // Queued for the pool, then a preview starts. + request(1..40); + f.set_playhead(Some(playhead)); + // Queued for the lanes, then the preview stops. + request(40..80); + f.set_playhead(None); + wait_for_renders(&f); + // Queued for the lanes, then the viewer shows one of them. + for frame in 80..120 { + f.request_background(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + f.request_urgent(&src, FrameKey { frame: 119, ..series }, cid, FrameRate::FPS_30.tick_of(119), RenderOpts::default()); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 119); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the @@ -895,33 +1022,59 @@ fn adding_a_source_does_not_hide_changes_to_existing_dependencies() { } } +/// Adding, moving or deleting a marker (Num* while a preview plays) keeps the cached frames: +/// the sound would otherwise stop at the next frame that has to be rendered again (#1083). +/// Only expressions that read markers make them part of the pixels. #[test] -fn markers_keep_the_frames() { +fn markers_keep_the_frames_unless_an_expression_reads_them() { use effectcraft_engine::Session; use serde_json::json; let mut s = Session::default(); let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); - let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); - let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); + let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer"); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer"); let f = Frames::default(); - let at = |s: &Session| f.content_of(&s.project, s.revision, outer); - let o0 = at(&s); - s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); - assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames"); + let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c); + let (o0, i0) = (at(&s, outer), at(&s, inner)); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 0.5, "comment": "beat"})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.0})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.5})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "comp markers"); + s.execute("markers.set", json!({"comp": inner.0, "layer": solid, "new": true, "time": 0.25})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "layer marker"); + // An expression reading markers makes them count again. + s.execute("prop.setExpression", json!({"comp": inner.0, "layer": solid, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let (o1, i1) = (at(&s, outer), at(&s, inner)); + assert_ne!(o1, o0); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + assert_ne!(at(&s, inner), i1, "marker read by an expression"); + assert_ne!(at(&s, outer), o1, "marker read by an expression in a nested comp"); } +/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it +/// changes the frames even though plain markers do not; and any expression, which can read +/// markers, makes every marker count. #[test] -fn markers_count_when_an_expression_can_read_them() { +fn timing_markers_and_expressions_still_change_the_frames() { use effectcraft_engine::Session; use serde_json::json; let mut s = Session::default(); let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); - s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap(); let f = Frames::default(); let at = |s: &Session| f.content_of(&s.project, s.revision, outer); let o0 = at(&s); s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); - assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change"); + assert_eq!(at(&s), o0, "a plain marker changes no pixels, so it keeps the frames"); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 0.25})).unwrap(); + s.execute("markers.set", json!({"comp": outer.0, "index": 0, "duration": 0.25, "protected": true})).unwrap(); + let o1 = at(&s); + assert_ne!(o1, o0, "a protected time marker with a duration sets the timing"); + s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap(); + let o2 = at(&s); + s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); + assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 3eee89453..8710f1f9a 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,6 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; +use effectcraft_engine::geom::vec2 as gv2; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; @@ -93,9 +94,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: )); } app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + // The drawn tooltip asks the catalog; the automation label above keeps the English name. + let label = crate::i18n::tr(cur.label()); let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), + Some(s) => format!("{} ({})", label, crate::menus::shortcut_text(s)), + None => label.to_string(), }; let resp = resp.on_hover_text(tip); let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); @@ -109,6 +112,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) { app.ui.status = e; + } else if resp.double_clicked() + && let Some((id, params)) = create_on_double_click(app, cur) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) + { + app.ui.status = e; } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); @@ -139,7 +147,7 @@ fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[T let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + let row = ui.selectable_label(app.ui.tool == *tool, crate::i18n::tr(tool.label())); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); if row.clicked() { app.ui.tool = *tool; @@ -374,6 +382,58 @@ fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } } +/// Double-clicking a creation tool in the Tools bar makes an instance of what it draws, as in +/// After Effects (#1072): a text layer from the Type tools; from the shape tools, a shape that +/// fills the comp — into the selected shape layer's Contents, as a mask on the selected layer +/// (any non-shape layer, or Tool Creates Mask), or as a new shape layer when neither applies. +fn create_on_double_click(app: &EffectcraftApp, tool: Tool) -> Option<(&'static str, serde_json::Value)> { + match tool { + // `invoke` turns empty params into an insertion point in the comp centre that starts + // editing (#866), which is what double-clicking the Type tool should do. + Tool::Type | Tool::TypeVertical => Some(("layer.newText", json!({}))), + Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => { + let kind = match tool { + Tool::Rectangle => "rect", + Tool::RoundedRect => "rounded", + Tool::Ellipse => "ellipse", + Tool::Polygon => "polygon", + _ => "star", + }; + // Without a comp the command still runs and reports its error on the status line. + let Some(comp) = app.session.active_comp() else { + return Some(("shape.newShape", json!({"kind": kind}))); + }; + let (cw, ch) = (comp.width as f64, comp.height as f64); + // Rectangles and ellipses fill the comp; a Polystar's Outer Radius is half the comp + // height (Inner Radius: a quarter of it), so it gets a comp-height square. + let (w, h) = if matches!(tool, Tool::Polygon | Tool::Star) { (ch, ch) } else { (cw, ch) }; + // A mask on the selected layer: any non-shape layer (the tool switches to Mask by + // itself), or a shape layer while Tool Creates Mask is on — as dragging does. + let sel = app.session.state.selected_layers.first().copied().and_then(|id| comp.layer(id)); + if let Some(l) = sel.filter(|l| l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape))) { + // The box in layer space, so a moved, scaled or rotated layer still gets a + // comp-filling mask: all four corners are mapped, and their bounds are the + // smallest axis-aligned box around the comp box. + let ctx = effectcraft_engine::render::EvalCtx::new(&app.session.project, app.session.active_comp_id()?, comp, app.session.time()); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_engine::geom::Mat3::IDENTITY); + let (x0, y0, x1, y1) = (cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0, cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0); + let pts = [gv2(x0, y0), gv2(x1, y0), gv2(x1, y1), gv2(x0, y1)].map(|p| inv.apply(p)); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in pts { + lo = [lo[0].min(p.x), lo[1].min(p.y)]; + hi = [hi[0].max(p.x), hi[1].max(p.y)]; + } + let rect = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; + return Some(("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect}))); + } + // Into the selected shape layer's Contents, else a new shape layer (the engine's own + // target), painted with the Tools bar's Fill and Stroke. + Some(("shape.newShape", json!({"kind": kind, "size": [w, h]}))) + } + _ => None, + } +} + /// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to /// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`). fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) { @@ -834,4 +894,254 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } + + /// Double-clicking a creation tool in the Tools bar makes an instance of what it draws + /// (#1072): with nothing selected a shape layer whose shape fills the comp, into the selected + /// shape layer's Contents, a mask on a non-shape layer, a text layer from the Type tools. + /// Tools that create nothing stay quiet. + #[test] + fn double_clicking_creation_tools_makes_instances() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Both click pairs in one frame, as egui counts a double-click. + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Nothing selected: a shape layer with a Rectangle Path 1 the size of the comp. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("Rectangle Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the rectangle fills the comp"); + } + // The shape layer is selected: the Ellipse goes on top of its Contents, no new layer. + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "into the selected shape layer's Contents"); + let l = &comp.layers[0]; + assert!(l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").is_some(), "the new ellipse is on top"); + assert!(l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").is_some(), "the rectangle still stands"); + } + // The Type tool: a text layer above the selection, editing started (an insertion point, #866). + double_click(&mut app, &ctx, 3.0, "tools.Type"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2); + assert!(matches!(comp.layers[0].source, LayerSource::Text)); + assert!(app.session.state.text_edit.is_some(), "the text layer is being edited"); + } + // A non-shape layer is selected, so the Rectangle becomes a comp-filling mask on it. + app.ui.slot_tools[8] = Tool::Rectangle; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 4.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a mask, not a layer"); + let p = comp.layers[0].props.prop("masks/Mask 1/path").expect("Mask 1"); + let sh = p.value.as_path().expect("a mask path"); + let b = effectcraft_path::bounds(&[effectcraft_path::to_kurbo(sh)]).expect("bounds"); + // The text layer is anchored top-left and centred in the comp: layer = comp - centre. + for (got, want) in [(b.x0, -320.0), (b.y0, -180.0), (b.x1, 320.0), (b.y1, 180.0)] { + assert!((got - want).abs() < 1.0, "the mask fills the comp: {got} vs {want}"); + } + } + // A tool that creates nothing: no layer, no error. + double_click(&mut app, &ctx, 5.0, "tools.Hand"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 2, "the Hand tool creates nothing"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + // Without a comp the command fails with the status line, never a panic. + let mut bare = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &bare.tokens, crate::i18n::language(&bare)); + frame(&mut bare, &ctx, 0.0, vec![]); + frame(&mut bare, &ctx, 0.1, vec![]); + double_click(&mut bare, &ctx, 1.0, "tools.Rectangle"); + assert!(!bare.ui.status.is_empty(), "no comp: a status message, not a crash"); + } + + /// #1765: a double-click on the Ellipse tool with nothing selected makes its own shape layer + /// the size of the comp, as the Rectangle does. + #[test] + fn double_click_ellipse_with_no_selection_makes_a_layer() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.0, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let e = app.auto.elements.iter().find(|w| w.id == "tools.Ellipse").unwrap_or_else(|| panic!("no tools.Ellipse")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(&mut app, &ctx, 1.1, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").expect("Ellipse Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the ellipse fills the comp"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// Tool Creates Mask on (#1072): a double-click on a shape tool masks the selected shape + /// layer instead of adding to its Contents. + #[test] + fn double_click_masks_a_shape_layer_while_tool_creates_mask() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // A shape layer, selected by the double-click that made it. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1); + app.session.state.shape_tool.creates_mask = true; + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "a mask, not a second shape layer"); + let l = &comp.layers[0]; + assert!(l.props.prop("masks/Mask 1/path").is_some(), "an ellipse mask"); + assert!(l.props.prop("contents/Ellipse 1").is_none(), "nothing added to the Contents"); + let p = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("the rectangle stands"); + assert_eq!(p.value.as_vec2(), [640.0, 360.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// The shape tools' geometry defaults (#1072): the Rounded Rectangle tool's Roundness is a + /// fixed 20, the Polygon tool a 5-point polygon, the Star tool a 5-point star whose Inner + /// Radius is a quarter of its Outer Radius. + #[test] + fn double_click_shape_tool_geometry_defaults() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Each shape on its own layer: deselect so the next double-click makes a new one. + let deselected = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64| { + app.session.execute("edit.deselectAll", json!({})).unwrap(); + frame(app, ctx, t, vec![]); + }; + // The Rounded Rectangle: Roundness 20, filling the comp. + app.ui.slot_tools[8] = Tool::RoundedRect; + frame(&mut app, &ctx, 1.0, vec![]); + double_click(&mut app, &ctx, 1.1, "tools.RoundedRect"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents/Rectangle Path 1").expect("Rectangle Path 1"); + assert_eq!(g.prop("roundness").expect("roundness").value.as_f64(), 20.0, "a fixed roundness"); + assert_eq!(g.prop("size").expect("size").value.as_vec2(), [640.0, 360.0]); + } + // The Star: 5 points, Outer Radius half the comp height, Inner Radius a quarter. + deselected(&mut app, &ctx, 2.0); + app.ui.slot_tools[8] = Tool::Star; + frame(&mut app, &ctx, 2.05, vec![]); + double_click(&mut app, &ctx, 2.1, "tools.Star"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 0, "Star"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert_eq!(g.prop("innerRadius").expect("inner").value.as_f64(), 90.0, "a quarter of the comp height"); + } + // The Polygon: 5 points, Outer Radius half the comp height, no inner radius. + deselected(&mut app, &ctx, 3.0); + app.ui.slot_tools[8] = Tool::Polygon; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 3.1, "tools.Polygon"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 1, "Polygon"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert!(g.prop("innerRadius").is_none(), "a polygon has no inner radius"); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// A double-clicked shape is painted with the Tools bar's Fill and Stroke (#1072): the + /// colours and stroke width the shape tool is set to — and no outline at all when the tool + /// has its Stroke off. + #[test] + fn double_click_paints_with_the_shape_tools_fill_and_stroke() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // The tool's own Fill and Stroke go onto the created shape. + app.session.state.shape_tool.fill.color = [1.0, 0.0, 0.0]; + app.session.state.shape_tool.stroke.color = [0.0, 1.0, 0.0]; + app.session.state.shape_tool.stroke_width = 5.0; + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents").expect("the contents"); + let fill = g.sub("fill").expect("the fill"); + assert_eq!(fill.get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0], "the tool's Fill"); + let stroke = g.sub("stroke").expect("the stroke"); + assert_eq!(stroke.get("color").expect("color").value.as_color(), [0.0, 1.0, 0.0, 1.0], "the tool's Stroke"); + assert_eq!(stroke.get("width").expect("width").value.as_f64(), 5.0, "the tool's Stroke Width"); + } + // Stroke off ("Keine"): the next shape gets no outline at all, but keeps the Fill. + app.session.execute("shape.toolOptions", json!({"stroke": false})).unwrap(); + app.session.execute("edit.deselectAll", json!({})).unwrap(); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a new shape layer"); + let g = comp.layers[0].props.group("contents/Ellipse 1/contents").expect("the ellipse"); + assert!(g.sub("stroke").is_none(), "no outline when the tool's Stroke is off"); + assert_eq!(g.sub("fill").expect("the fill").get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 611a226ee..254eec49c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -123,7 +123,7 @@ fn web_draws(language: &str) -> bool { /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, /// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`, -/// `ka-GE` → `ka`). +/// `ka-GE` → `ka`, `ru-RU` → `ru`, `de-AT` → `de`, `fr-CA` → `fr`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -135,6 +135,9 @@ fn supported(locale: Option<&str>) -> &'static str { "es" => "es", "cs" => "cs", "ka" => "ka", + "ru" => "ru", + "de" => "de", + "fr" => "fr", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -252,22 +255,32 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin return effects::name(id, &e.label); } // Undo/redo names come from the history, so they are formatted per language here rather than - // looked up in a catalog. An English (or untranslated) interface keeps the shown label. - let (undo, no_undo, redo, no_redo) = match language(app) { - "ja" => ("取り消し", "取り消しできません", "やり直し", "やり直しできません"), - "zh-hans" => ("撤销", "无可撤销的操作", "重做", "无可重做的操作"), - "zh-hant" => ("復原", "沒有可復原的操作", "重做", "沒有可重做的操作"), - "uk" => ("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення"), - "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), - "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), - "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + // looked up in a catalog. A language without this wording keeps the shown undo/redo label, and + // the rest of its menu still goes through the catalog below (#1449). + let history = match language(app) { + "en" => return shown, + "ja" => Some(("取り消し", "取り消しできません", "やり直し", "やり直しできません")), + "zh-hans" => Some(("撤销", "无可撤销的操作", "重做", "无可重做的操作")), + "zh-hant" => Some(("復原", "沒有可復原的操作", "重做", "沒有可重做的操作")), + "uk" => Some(("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення")), + "es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")), + "pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")), + "cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". - "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), - _ => return shown, + "ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")), + // A colon before the history's (untranslated) action name: "Отменить: Comp1". + "ru" => Some(("Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять")), + _ => None, }; match e.command.as_str() { - "edit.undo" => app.session.history.undo.last().map(|u| format!("{undo} {}", u.0)).unwrap_or_else(|| no_undo.into()), - "edit.redo" => app.session.history.redo.last().map(|u| format!("{redo} {}", u.0)).unwrap_or_else(|| no_redo.into()), + "edit.undo" | "edit.redo" => { + let Some((undo, no_undo, redo, no_redo)) = history else { return shown }; + if e.command == "edit.undo" { + app.session.history.undo.last().map(|u| format!("{undo} {}", u.0)).unwrap_or_else(|| no_undo.into()) + } else { + app.session.history.redo.last().map(|u| format!("{redo} {}", u.0)).unwrap_or_else(|| no_redo.into()) + } + } "window.panel" => { let prefix = format!("{}: ", e.label); if let Some(name) = shown.strip_prefix(&prefix) { @@ -7569,9 +7582,19 @@ mod tests { (Some("ka"), "ka"), (Some("ka-GE"), "ka"), (Some("ka_GE.UTF-8"), "ka"), + (Some("ru"), "ru"), + (Some("ru-RU"), "ru"), + (Some("ru-SK"), "ru"), + (Some("ru_RU.UTF-8"), "ru"), + (Some("de"), "de"), + (Some("de-DE"), "de"), + (Some("de_AT.UTF-8"), "de"), + (Some("fr"), "fr"), + (Some("fr-FR"), "fr"), + (Some("fr_CA.UTF-8"), "fr"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), - (Some("de-DE"), "en"), + (Some("nl-NL"), "en"), (None, "en"), ] { assert_eq!(supported(locale), language, "{locale:?}"); @@ -7629,6 +7652,35 @@ mod tests { assert_eq!(code("unknown"), "en"); } + /// Every catalog language draws its menu entries from the catalog, including those with no + /// undo/redo wording of their own: choosing Deutsch, Русский or Français left the menus in + /// English (#1449). + #[test] + fn every_catalog_language_translates_menu_entries() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let menu_entry = |command: &str, label: &str| MenuEntry { + label: label.into(), + command: command.into(), + params: serde_json::Value::Null, + shortcut: None, + alt_shortcuts: Vec::new(), + }; + let new_comp = menu_entry("comp.new", "New Composition..."); + let undo = menu_entry("edit.undo", "Undo"); + for code in CATALOG_CODES { + app.session.execute("prefs.set", json!({"key":"general.language", "value":code})).unwrap(); + let drawn = entry(&app, &new_comp, new_comp.label.clone()); + assert_eq!(drawn, label(&app, "comp.new", "New Composition..."), "{code}"); + assert_ne!(drawn, new_comp.label, "{code}: the menu entry is drawn in English"); + } + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label"); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); + assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 162300f9c..0e767f5ac 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -143,6 +143,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "雾 3D"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 图层实例"), ("ec.audio.backwards", "Backwards", "倒放"), ("ec.audio.basstreble", "Bass & Treble", "低音和高音"), ("ec.audio.compressor", "Compressor", "压缩程序"), @@ -451,31 +452,313 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, /// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the -/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`, -/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that -/// English name here rather than being left out. +/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name +/// here rather than being left out. An effect with no row draws its English name anyway, which is +/// what the `OCIO` set, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects do. /// -/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface -/// (its generated Effect menu and its Effects & Presets panel). +/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu +/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to +/// hand. A published glossary is used to cross-check, never as the only source: it normalises the +/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for +/// the `CC` series, which AE 2026 leaves in English. const JAPANESE_EFFECTS: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3Dチャンネル抽出"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "デプスマット"), + ("ec.3d.depthoffield", "Depth of Field", "被写界深度"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "フォグ3D"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "IDマット"), + ("ec.audio.backwards", "Backwards", "リバース"), + ("ec.audio.basstreble", "Bass & Treble", "バス&トレブル"), + ("ec.audio.compressor", "Compressor", "コンプレッサー"), + ("ec.audio.delay", "Delay", "ディレイ"), + ("ec.audio.distortion", "Distortion", "歪み"), + ("ec.audio.flangechorus", "Flange & Chorus", "フランジ&コーラス"), + ("ec.audio.gate", "Gate", "ゲート"), + ("ec.audio.highlowpass", "High-Low Pass", "ハイパス/ローパス"), + ("ec.audio.modulator", "Modulator", "モジュレーター"), + ("ec.audio.parametriceq", "Parametric EQ", "パラメトリックEQ"), + ("ec.audio.reverb", "Reverb", "リバーブ"), + ("ec.audio.stereomixer", "Stereo Mixer", "ステレオミキサー"), + ("ec.audio.tone", "Tone", "トーン"), ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), ("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"), ("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"), ("ec.blur.radial", "Radial Blur", "ブラー(放射状)"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "インターレースのちらつき削減"), + ("ec.blur.sharpen", "Sharpen", "シャープ"), + ("ec.blur.smart", "Smart Blur", "ブラー(詳細)"), + ("ec.blur.unsharp", "Unsharp Mask", "アンシャープマスク"), + ("ec.channel.arithmetic", "Arithmetic", "アリスマチック"), + ("ec.channel.blend", "Blend", "ブレンド"), + ("ec.channel.calculations", "Calculations", "計算"), + ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.combiner", "Channel Combiner", "チャンネルコンバイナー"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "合成アリスマチック"), + ("ec.channel.invert", "Invert", "反転"), + ("ec.channel.minimax", "Minimax", "最大/最小"), + ("ec.channel.removecolormatting", "Remove Color Matting", "カラーマット削除"), + ("ec.channel.setchannels", "Set Channels", "チャンネル設定"), + ("ec.channel.setmatte", "Set Matte", "マット設定"), + ("ec.channel.shiftchannels", "Shift Channels", "チャンネルシフト"), + ("ec.channel.solidcomposite", "Solid Composite", "単色合成"), + ("ec.color.autocolor", "Auto Color", "自動カラー補正"), + ("ec.color.autocontrast", "Auto Contrast", "自動コントラスト"), + ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), + ("ec.color.blackwhite", "Black & White", "白黒"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "色を変更"), + ("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"), + ("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"), + ("ec.color.colorama", "Colorama", "コロラマ"), + ("ec.color.colorbalance", "Color Balance", "カラーバランス"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "カラーバランス(HLS)"), + ("ec.color.colorlink", "Color Link", "カラーリンク"), + ("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"), + ("ec.color.curves", "Curves", "トーンカーブ"), + ("ec.color.equalize", "Equalize", "平均化(イコライズ)"), + ("ec.color.exposure", "Exposure", "露光量"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "ガンマ/ペデスタル/ゲイン"), + ("ec.color.huesaturation", "Hue/Saturation", "色相/彩度"), + ("ec.color.leavecolor", "Leave Color", "色抜き"), + ("ec.color.levels", "Levels", "レベル補正"), + ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 変換"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色空間変換"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 表示変換"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO ファイル変換"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO Look トランスフォーム"), + ("ec.color.photofilter", "Photo Filter", "レンズフィルター"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), + ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"), + ("ec.color.tint", "Tint", "色かぶり補正"), + ("ec.color.tritone", "Tritone", "トライトーン"), + ("ec.color.vibrance", "Vibrance", "自然な彩度"), + ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), + ("ec.control.angle", "Angle Control", "角度制御"), + ("ec.control.checkbox", "Checkbox Control", "チェックボックス制御"), + ("ec.control.color", "Color Control", "カラー制御"), + ("ec.control.dropdown", "Dropdown Menu Control", "ドロップダウンメニュー制御"), + ("ec.control.layer", "Layer Control", "レイヤー制御"), + ("ec.control.point", "Point Control", "ポイント制御"), + ("ec.control.point3d", "3D Point Control", "3D ポイント制御"), + ("ec.control.slider", "Slider Control", "スライダー制御"), + ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), + ("ec.distort.bulge", "Bulge", "バルジ"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "コーナーピン"), + ("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"), + ("ec.distort.liquify", "Liquify", "ゆがみ"), + ("ec.distort.magnify", "Magnify", "ズーム"), + ("ec.distort.meshwarp", "Mesh Warp", "メッシュワープ"), + ("ec.distort.mirror", "Mirror", "ミラー"), + ("ec.distort.offset", "Offset", "オフセット"), + ("ec.distort.opticscompensation", "Optics Compensation", "レンズ補正"), + ("ec.distort.polar", "Polar Coordinates", "極座標"), + ("ec.distort.reshape", "Reshape", "リシェープ"), + ("ec.distort.ripple", "Ripple", "波紋"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "ローリングシャッターの修復"), + ("ec.distort.smear", "Smear", "にじみ"), + ("ec.distort.spherize", "Spherize", "球面"), + ("ec.distort.transform", "Transform", "トランスフォーム"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "タービュレントディスプレイス"), + ("ec.distort.twirl", "Twirl", "回転"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "Twirl (Legacy)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(詳細を保持)"), + ("ec.distort.warp", "Warp", "ワープ"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"), + ("ec.distort.wavewarp", "Wave Warp", "波形ワープ"), + ("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"), + ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), + ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), + ("ec.generate.beam", "Beam", "レーザー"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "セルパターン"), + ("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"), + ("ec.generate.circle", "Circle", "円"), + ("ec.generate.ellipse", "Ellipse", "楕円"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "スポイト塗り"), + ("ec.generate.fill", "Fill", "塗り"), + ("ec.generate.fourcolor", "4-Color Gradient", "4色グラデーション"), + ("ec.generate.fractal", "Fractal", "フラクタル"), + ("ec.generate.gradientramp", "Gradient Ramp", "グラデーション"), + ("ec.generate.grid", "Grid", "グリッド"), + ("ec.generate.lensflare", "Lens Flare", "レンズフレア"), + ("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"), + ("ec.generate.radiowaves", "Radio Waves", "電波"), + ("ec.generate.scribble", "Scribble", "落書き"), + ("ec.generate.stroke", "Stroke", "線"), + ("ec.generate.vegas", "Vegas", "ベガス"), + ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.colordifference", "Color Difference Key", "カラー差キー"), ("ec.key.colorkey", "Color Key", "カラーキー"), + ("ec.key.colorrange", "Color Range", "色範囲"), + ("ec.key.differencematte", "Difference Matte", "異なるマット"), + ("ec.key.extract", "Extract", "抽出"), + ("ec.key.innerouter", "Inner/Outer Key", "インナー/アウターキー"), + ("ec.key.keycleaner", "Key Cleaner", "Key Cleaner"), + ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), - ("ec.obsolete.basic3d", "Basic 3D", "基本 3D"), + ("ec.keying.keylight", "Key Light", "Keylight (1.2)"), + ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), + ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), + ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), + ("ec.matte.simplechoker", "Simple Choker", "チョーク"), + ("ec.noise.addgrain", "Add Grain", "グレイン(追加)"), + ("ec.noise.curlnoise", "Curl Noise", "カールノイズ"), + ("ec.noise.dustscratches", "Dust & Scratches", "ダスト&スクラッチ"), + ("ec.noise.fractal", "Fractal Noise", "フラクタルノイズ"), + ("ec.noise.matchgrain", "Match Grain", "グレイン(マッチ)"), + ("ec.noise.median", "Median", "ミディアン"), + ("ec.noise.medianlegacy", "Median (Legacy)", "ミディアン(レガシー)"), + ("ec.noise.noise", "Noise", "ノイズ"), + ("ec.noise.noisealpha", "Noise Alpha", "ノイズアルファ"), + ("ec.noise.noisehls", "Noise HLS", "ノイズHLS"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "ノイズHLSオート"), + ("ec.noise.removegrain", "Remove Grain", "グレイン(除去)"), + ("ec.noise.turbulent", "Turbulent Noise", "タービュレントノイズ"), + ("ec.obsolete.basic3d", "Basic 3D", "基本3D"), ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), + ("ec.obsolete.mochashape", "Mocha shape", "mocha shape"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), + ("ec.perspective.3dglasses", "3D Glasses", "3Dメガネ"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "ベベルアルファ"), + ("ec.perspective.beveledges", "Bevel Edges", "ベベルエッジ"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D カメラトラッカー"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), + ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), + ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.dropshadow", "Drop Shadow", "ドロップシャドウ"), + ("ec.perspective.radialshadow", "Radial Shadow", "放射状シャドウ"), + ("ec.sim.carddance", "Card Dance", "カードダンス"), + ("ec.sim.caustics", "Caustics", "コースティック"), + ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), + ("ec.sim.cchair", "CC Hair", "CC Hair"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.foam", "Foam", "泡"), + ("ec.sim.particleplayground", "Particle Playground", "パーティクルプレイグラウンド"), + ("ec.sim.shatter", "Shatter", "シャター"), + ("ec.sim.waveworld", "Wave World", "ウェーブワールド"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"), + ("ec.stylize.cartoon", "Cartoon", "カートゥーン"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"), + ("ec.stylize.emboss", "Emboss", "エンボス"), + ("ec.stylize.findedges", "Find Edges", "輪郭検出"), + ("ec.stylize.glow", "Glow", "グロー"), + ("ec.stylize.mosaic", "Mosaic", "モザイク"), + ("ec.stylize.motiontile", "Motion Tile", "モーションタイル"), + ("ec.stylize.posterize", "Posterize", "ポスタリゼーション"), + ("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"), + ("ec.stylize.scatter", "Scatter", "拡散"), + ("ec.stylize.strobe", "Strobe Light", "ストロボ"), + ("ec.stylize.texturize", "Texturize", "テクスチャ"), + ("ec.stylize.threshold", "Threshold", "しきい値"), + ("ec.text.numbers", "Numbers", "番号"), + ("ec.text.timecode", "Timecode", "タイムコード"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), + ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.echo", "Echo", "エコー"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), + ("ec.time.posterizetime", "Posterize Time", "ポスタリゼーション時間"), + ("ec.time.timedifference", "Time Difference", "時間差"), + ("ec.time.timedisplacement", "Time Displacement", "時間置き換え"), + ("ec.time.timewarp", "Timewarp", "タイムワープ"), + ("ec.transition.blockdissolve", "Block Dissolve", "ブロックディゾルブ"), + ("ec.transition.cardwipe", "Card Wipe", "カードワイプ"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), + ("ec.transition.cctwister", "CC Twister", "CC Twister"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.gradientwipe", "Gradient Wipe", "グラデーションワイプ"), + ("ec.transition.iriswipe", "Iris Wipe", "アイリスワイプ"), + ("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"), + ("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"), + ("ec.transition.venetian", "Venetian Blinds", "ブラインド"), + ("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"), + ("ec.utility.growbounds", "Grow Bounds", "範囲拡張"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR コンパンダー"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ハイライト圧縮"), + ("ec.vr.blur", "VR Blur", "VR ブラー"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR 色収差"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR カラーグラデーション"), + ("ec.vr.converter", "VR Converter", "VR コンバーター"), + ("ec.vr.denoise", "VR De-Noise", "VR ノイズ除去"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR デジタルグリッチ"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR フラクタルノイズ"), + ("ec.vr.glow", "VR Glow", "VR グロー"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR 平面として投影"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR 回転(球)"), + ("ec.vr.sharpen", "VR Sharpen", "VR シャープ"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球体から平面"), ]; /// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. @@ -490,12 +773,13 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), ("Expression Controls", "式制御"), + ("Generate", "描画"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), ("Noise & Grain", "ノイズ&グレイン"), ("Obsolete", "旧バージョン"), - ("Paint", "描画"), + ("Paint", "ペイント"), ("Perspective", "遠近"), ("Simulation", "シミュレーション"), ("Stylize", "スタイライズ"), @@ -519,6 +803,7 @@ const GEORGIAN: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.3d.instance3d", "3D Layer Instance", "3D Layer Instance"), ("ec.audio.backwards", "Backwards", "უკუღმა"), ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), ("ec.audio.compressor", "Compressor", "კომპრესორი"), @@ -1001,3 +1286,21 @@ mod tests { assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } } + +#[cfg(test)] +mod japanese_category_tests { + use super::*; + use std::collections::HashMap; + + /// Two categories drawing the same Japanese name would make the Effect menu show one name twice and + /// leave the user unable to tell them apart. `描画` was both `Generate` and `Paint` before #2583. + #[test] + fn no_two_categories_share_a_japanese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, japanese) in JAPANESE_CATEGORIES { + if let Some(other) = seen.insert(japanese, english) { + panic!("`{english}` and `{other}` both draw as `{japanese}`"); + } + } + } +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 46493296b..061090871 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -467,6 +467,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Overlay", "オーバーレイ"), ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), + ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセル縦横比:"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), @@ -1199,6 +1200,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Overlay", "叠加"), ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), + ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), @@ -1641,6 +1643,49 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Selection Tool", "选择工具"), + ("Hand Tool", "抓手工具"), + ("Zoom Tool", "缩放工具"), + ("Orbit Around Cursor Tool", "绕行摄像机工具"), + ("Pan Under Cursor Tool", "平移摄像机工具"), + ("Dolly Towards Cursor Tool", "推拉摄像机工具"), + ("Rotation Tool", "旋转工具"), + ("Pan Behind (Anchor Point) Tool", "向后平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圆角矩形工具"), + ("Ellipse Tool", "椭圆工具"), + ("Polygon Tool", "多边形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "钢笔工具"), + ("Add Vertex Tool", "添加顶点工具"), + ("Delete Vertex Tool", "删除顶点工具"), + ("Convert Vertex Tool", "转换顶点工具"), + ("Mask Feather Tool", "蒙版羽化工具"), + ("Horizontal Type Tool", "横排文字工具"), + ("Vertical Type Tool", "直排文字工具"), + ("Brush Tool", "画笔工具"), + ("Clone Stamp Tool", "仿制图章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 笔刷工具"), + ("Refine Edge Tool", "调整边缘工具"), + ("Object Matte Tool", "对象遮罩工具"), + ("Puppet Position Pin Tool", "人偶位置控点工具"), + ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), + ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), + ("Puppet Advanced Pin Tool", "人偶高级控点工具"), + ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), + ("Shortcut", "快捷键"), + ("Range", "范围"), + ("Play From", "从以下位置播放"), + ("Frame Rate", "帧速率"), + ("Resolution", "分辨率"), + ("Overlays", "叠加"), + ("Layer Controls", "图层控制"), + ("Loop", "循环"), + ("Cache Before Playback", "播放前缓存"), + ("Pre-roll", "预卷"), + ("Post-roll", "后卷"), + ("If caching, play cached frames", "缓存时播放已缓存帧"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1939,6 +1984,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Overlay", "疊加"), ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), + ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), @@ -2327,6 +2373,49 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Selection Tool", "選取工具"), + ("Hand Tool", "手形工具"), + ("Zoom Tool", "縮放工具"), + ("Orbit Around Cursor Tool", "繞行攝影機工具"), + ("Pan Under Cursor Tool", "平移攝影機工具"), + ("Dolly Towards Cursor Tool", "推拉攝影機工具"), + ("Rotation Tool", "旋轉工具"), + ("Pan Behind (Anchor Point) Tool", "向後平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圓角矩形工具"), + ("Ellipse Tool", "橢圓工具"), + ("Polygon Tool", "多邊形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "筆型工具"), + ("Add Vertex Tool", "添加頂點工具"), + ("Delete Vertex Tool", "刪除頂點工具"), + ("Convert Vertex Tool", "轉換頂點工具"), + ("Mask Feather Tool", "遮色片羽化工具"), + ("Horizontal Type Tool", "橫排文字工具"), + ("Vertical Type Tool", "垂直文字工具"), + ("Brush Tool", "筆刷工具"), + ("Clone Stamp Tool", "仿製圖章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 筆刷工具"), + ("Refine Edge Tool", "調整邊緣工具"), + ("Object Matte Tool", "物件遮色片工具"), + ("Puppet Position Pin Tool", "人偶位置控點工具"), + ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), + ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), + ("Puppet Advanced Pin Tool", "人偶高級控點工具"), + ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), + ("Shortcut", "快速鍵"), + ("Range", "範圍"), + ("Play From", "從下列位置播放"), + ("Frame Rate", "影格速率"), + ("Resolution", "解析度"), + ("Overlays", "覆疊"), + ("Layer Controls", "圖層控制"), + ("Loop", "循環"), + ("Cache Before Playback", "播放前快取"), + ("Pre-roll", "預卷"), + ("Post-roll", "後卷"), + ("If caching, play cached frames", "快取時播放已快取影格"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2639,6 +2728,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), + ("Track Matte pick whip: drag onto a layer", "Зв’язування трекової маски: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), ("Playing on Video Preview", "Відтворення на відеоперегляді"), ("Post-Render Action: {}", "Дія після рендерингу: {}"), @@ -3192,6 +3282,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Output To:", "Saída para:"), ("Parameter Name:", "Nome do parâmetro:"), ("Parent pick whip: drag onto a layer", "Pick whip de pai: arraste até uma camada"), + ("Track Matte pick whip: drag onto a layer", "Pick whip de track matte: arraste até uma camada"), ("Pixel Aspect Ratio:", "Proporção de pixel:"), ("Playing on Video Preview", "Reproduzindo na visualização de vídeo"), ("Post-Render Action: {}", "Ação pós-renderização: {}"), @@ -3353,6 +3444,305 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("⚠ {} is used by:", "⚠ {} é usado por:"), ("Total Offset", "Deslocamento total"), ("Per Layer", "Por camada"), + ("Action-safe", "Área segura de ação"), + ("Add to favorites", "Adicionar aos favoritos"), + ("All Footage", "Todas as mídias"), + ("All Keyframes", "Todos os keyframes"), + ("Allow Scripts to Write Files and Access Network", "Permitir que scripts gravem arquivos e acessem a rede"), + ("Append Bit Depth to File Name", "Anexar profundidade de bits ao nome do arquivo"), + ("Application Scripting", "Scripting do aplicativo"), + ("Ask User", "Perguntar ao usuário"), + ("Audio Hardware", "Hardware de áudio"), + ("Audio Output Mapping", "Mapeamento de saída de áudio"), + ("Auto-Save Location", "Local do salvamento automático"), + ("Automatic", "Automático"), + ("Automatically Reload Footage", "Recarregar mídias automaticamente"), + ("Automatically Save Projects", "Salvar projetos automaticamente"), + ("Brightness", "Brilho"), + ("Cache Budgets", "Orçamentos de cache"), + ("Conformed Audio Folder", "Pasta de áudio conformado"), + ("Create Layers at Composition Start Time", "Criar camadas no tempo inicial da composição"), + ("Create Split Layers Above Original Layer", "Criar camadas divididas acima da camada original"), + ("Custom", "Personalizado"), + ("Custom Location", "Local personalizado"), + ("Cycle Mask Colors", "Alternar cores das máscaras"), + ("Dark Theme", "Tema escuro"), + ("Dashed Lines", "Linhas tracejadas"), + ("Default 3D Renderer", "Renderizador 3D padrão"), + ("Default Drag Import As", "Importação padrão ao arrastar como"), + ("Default Output Folder", "Pasta de saída padrão"), + ("Default Spatial Interpolation to Linear", "Definir interpolação espacial padrão como linear"), + ("Disable Video Output When in Background", "Desativar saída de vídeo em segundo plano"), + ("Disk Cache Folder", "Pasta de cache do disco"), + ("Display Color Space", "Espaço de cores da exibição"), + ("Dots", "Pontos"), + ("Duration (Seconds)", "Duração (segundos)"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "O EffectCraft sempre usa o próprio seletor de cores (o mesmo em todas as plataformas e na web).", + ), + ("Empty Disk Cache", "Esvaziar cache do disco"), + ("Enable Disk Cache", "Ativar cache do disco"), + ("Enable JavaScript Debugger", "Ativar depurador JavaScript"), + ("Enable Video Preview Output", "Ativar saída de pré-visualização de vídeo"), + ("Expression Pick Whip Writes Compact English", "Pick whip de expressão grava inglês compacto"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "As expressões usam o mecanismo JavaScript. Erros de script informam o arquivo e a linha no Console de scripts; não há depurador passo a passo.", + ), + ("Face Model", "Modelo de rosto"), + ("Face Tracking", "Rastreamento facial"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "O rastreamento facial no painel Rastreador (Somente contorno e recursos detalhados) usa o modelo escolhido aqui. Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.", + ), + ("Fast Previews (Draft 3D, Faster Effects)", "Pré-visualizações rápidas (3D em rascunho, efeitos mais rápidos)"), + ("Files per Segment", "Arquivos por segmento"), + ("Fit", "Ajustar"), + ("Fit up to 100%", "Ajustar até 100%"), + ("Floating Window", "Janela flutuante"), + ("Footage Frame Cache", "Cache de quadros de mídia"), + ("GPU Information...", "Informações da GPU..."), + ("Gridline Every", "Grade a cada"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Aceleração por hardware dos painéis de composição, camada e mídia"), + ("Import Photoshop File", "Importar arquivo do Photoshop"), + ("Interpret Unlabeled Alpha As", "Interpretar alfa não rotulado como"), + ("Keyframes", "Keyframes"), + ("Labels and Colors", "Rótulos e cores"), + ("Layer Cache", "Cache de camada"), + ("Length of Composition", "Duração da composição"), + ("Levels of Undo", "Níveis de desfazer"), + ("Light Theme", "Tema claro"), + ("Match System", "Seguir o sistema"), + ("Maximum Disk Cache Size", "Tamanho máximo do cache do disco"), + ("Maximum Project Versions", "Máximo de versões do projeto"), + ("Media Cache", "Cache de mídia"), + ("Menu Bar", "Barra de menus"), + ("Minutes", "Minutos"), + ("Mirror on Computer Monitor", "Espelhar no monitor do computador"), + ("Move Panel to New Window", "Mover painel para nova janela"), + ("New Composition", "Nova composição"), + ("New Project", "Novo projeto"), + ("New Project Loads Template", "Novo projeto carrega modelo"), + ("Next to Project", "Ao lado do projeto"), + ("No More Than (Keyframes)", "No máximo (keyframes)"), + ("No More Than (Seconds)", "No máximo (segundos)"), + ("No Motion Path", "Sem caminho de movimento"), + ("Non-Sequence Footage", "Mídia que não é sequência"), + ("Number of Recent Fonts to Display", "Número de fontes recentes a exibir"), + ("Off (Final Quality)", "Desativado (qualidade final)"), + ("Offer to Open the Latest Auto-Save After a Crash", "Oferecer para abrir o salvamento automático mais recente após uma falha"), + ("OpenGL (compatibility)", "OpenGL (compatibilidade)"), + ("Panels are always drawn by the GPU (wgpu).", "Os painéis são sempre desenhados pela GPU (wgpu)."), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Preservar o número constante de vértices e pontos de feather ao editar máscaras"), + ("Preview (RAM) Cache", "Cache de pré-visualização (RAM)"), + ("RAM Reserved for Other Applications", "RAM reservada para outros aplicativos"), + ("RGB Straight", "RGB direto"), + ("Realtime Shadows in Draft", "Sombras em tempo real no rascunho"), + ("Recent Projects Shown", "Projetos recentes exibidos"), + ("Red", "Vermelho"), + ("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"), + ("Remove from favorites", "Remover dos favoritos"), + ("Repair", "Reparo"), + ("Report Missing Frames", "Relatar quadros ausentes"), + ("Reset Keyboard Shortcuts", "Redefinir atalhos de teclado"), + ("Reset Settings", "Redefinir configurações"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Roto Brush 2.0 e 3.0 usam o modelo escolhido aqui (a versão 1.0 sempre usa o mecanismo clássico). Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.", + ), + ("Safe Margins", "Margens de segurança"), + ("Save Every", "Salvar a cada"), + ("Save Now", "Salvar agora"), + ("Save When Starting Render Queue", "Salvar ao iniciar a fila de renderização"), + ("Segment Movie Files", "Segmentar arquivos de filme"), + ("Segment Sequences", "Segmentar sequências"), + ("Segment Size", "Tamanho do segmento"), + ("Segmentation Model", "Modelo de segmentação"), + ("Sequence Footage", "Mídia em sequência"), + ("Show 3D Reference Axes", "Mostrar eixos de referência 3D"), + ("Show Expression Error Banner", "Mostrar faixa de erro de expressão"), + ("Show Font Names in English", "Mostrar nomes de fontes em inglês"), + ("Show Font Preview", "Mostrar pré-visualização de fonte"), + ("Show Home Screen When Launching", "Mostrar tela inicial ao iniciar"), + ("Show Home Screen When Opening a Project", "Mostrar tela inicial ao abrir um projeto"), + ("Show Tool Tips", "Mostrar dicas de ferramenta"), + ("Startup & Repair", "Inicialização e reparo"), + ("Still Footage", "Mídia estática"), + ("Stored, but not used by EffectCraft yet", "Armazenado, mas ainda não usado pelo EffectCraft"), + ("Subdivisions", "Subdivisões"), + ("Switches Affect Nested Comps", "Interruptores afetam composições aninhadas"), + ("Synchronize Time of All Related Items", "Sincronizar o tempo de todos os itens relacionados"), + ("Template Project", "Projeto de modelo"), + ("Title-safe", "Área segura de título"), + ("Use In-Window Menu Bar on macOS", "Usar barra de menus na janela no macOS"), + ("Use System Color Picker", "Usar seletor de cores do sistema"), + ("Video Device", "Dispositivo de vídeo"), + ("Video Output During Playback", "Saída de vídeo durante a reprodução"), + ("Warn User When Executing Files", "Avisar o usuário ao executar arquivos"), + ("Window Graphics", "Gráficos da janela"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Gráficos da janela vale a partir do próximo início. Quando o driver gráfico interrompe o EffectCraft na abertura da janela, o próximo início muda para OpenGL.", + ), + ("Wireframe", "Wireframe"), + ("Active", "Em uso"), + ("NAME", "NOME"), + ("Affect all layers that use this solid", "Afetar todas as camadas que usam este sólido"), + ("EffectCraft home page", "Página inicial do EffectCraft"), + ("Only this layer uses this solid.", "Somente esta camada usa este sólido."), + ("KIND", "TIPO"), + ("Blue", "Azul"), + ("Green", "Verde"), + ("Unfreeze", "Descongelar"), + ("Export", "Exportar"), + ("Auto-complete", "Preenchimento automático"), + ("Bracket Matching", "Correspondência de colchetes"), + ("Font Size", "Tamanho da fonte"), + ("Line Numbers", "Números de linha"), + ("Scripting & Expressions", "Scripts e expressões"), + ("Syntax Highlighting", "Realce de sintaxe"), + ("Word Wrap", "Quebra automática de linha"), + ("16 bits per channel", "16 bits por canal"), + ("32 bits per channel (float)", "32 bits por canal (ponto flutuante)"), + ("8 bits per channel", "8 bits por canal"), + ("Adobe-style built-in", "Integrado (estilo Adobe)"), + ("Composition - Retain Layer Sizes", "Composição - Manter tamanhos de camada"), + ("Guess", "Adivinhar"), + ("Import Image Sequence", "Importar sequência de imagens"), + ("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"), + ("Mercury Software Only", "Apenas software Mercury"), + ("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"), + ("Still Duration", "Duração da imagem fixa"), + ("Straight", "Direto"), + ("Tone map", "Mapeamento tonal"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (padrão)"), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Clique acima da barra para adicionar uma parada de opacidade, abaixo dela para adicionar uma parada de cor.", + ), + ("Location:", "Local:"), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Estes keyframes adjacentes não podem ser interpolados porque os seus números de paradas diferem.", + ), + ("After Effects project", "Projeto do After Effects"), + ("Create a new project, or open the demo project to look around.", "Crie um novo projeto ou abra o projeto de demonstração para explorar."), + ("Filter recent files", "Filtrar arquivos recentes"), + ("Import Footage…", "Importar mídia…"), + ("Just now", "Agora mesmo"), + ("Lottie animation", "Animação Lottie"), + ("Motion graphics and visual effects · version {}", "Motion graphics e efeitos visuais · versão {}"), + ("No recent files match the filter", "Nenhum arquivo recente corresponde ao filtro"), + ("OPENED", "ABERTO"), + ("Open Demo Project", "Abrir projeto de demonstração"), + ("Open Project…", "Abrir projeto…"), + ("SIZE", "TAMANHO"), + ("Try another name or folder.", "Tente outro nome ou pasta."), + ("Yesterday", "Ontem"), + ("Your recent files will show here", "Seus arquivos recentes aparecerão aqui"), + ("{} day ago", "há {} dia"), + ("{} days ago", "há {} dias"), + ("{} hour ago", "há {} hora"), + ("{} hours ago", "há {} horas"), + ("{} minute ago", "há {} minuto"), + ("{} minutes ago", "há {} minutos"), + ("Show Rendering Progress in Info Panel and Flowchart", "Mostrar progresso de renderização no painel Informações e no fluxograma"), + ("Current Settings", "Configurações atuais"), + ("Move Panel to Main Window", "Mover painel para a janela principal"), + ("Disable Per-character 3D", "Desativar 3D por caractere"), + ("Enable Per-character 3D", "Ativar 3D por caractere"), + ("Opus Bitrate: {} kbps", "Taxa de bits do Opus: {} kbps"), + ("Quarter", "Um quarto"), + ("Region of Interest", "Região de interesse"), + ("Third", "Um terço"), + ("Boundary", "Contorno"), + ("Labels", "Rótulos"), + ("You can't undo this.", "Você não pode desfazer isto."), + ("Latin", "Latino"), + ("South Asian and Middle Eastern", "Sul-asiático e do Oriente Médio"), + ("Text Engine", "Motor de texto"), + ("Variable fonts only", "Apenas fontes variáveis"), + ("Expressions Editor", "Editor de expressões"), + ("Faster", "Mais rápido"), + ("Grids & Guides", "Grades e guias"), + ("More Accurate", "Mais preciso"), + ("Path Point and Handle Size", "Tamanho dos pontos de caminho e das alças"), + ("Show Internal Wireframes", "Mostrar wireframes internos"), + ("Viewer Zoom Quality", "Qualidade de zoom do visualizador"), + ("Motion Path", "Caminho de movimento"), + ("3D Reference Axes", "Eixos de referência 3D"), + ("Choose grid and guide options", "Escolher opções de grade e guias"), + ("Colorize", "Colorir"), + ("Custom...", "Personalizado..."), + ("Proportional Grid", "Grade proporcional"), + ("Reset Exposure", "Redefinir exposição"), + ("Show Channel and Color Management Settings", "Mostrar configurações de canal e de gerenciamento de cores"), + ("Show Snapshot (hold)", "Mostrar instantâneo (manter pressionado)"), + ("Take Snapshot", "Capturar instantâneo"), + ("Toggle Mask and Shape Path Visibility", "Alternar visibilidade das máscaras e dos caminhos de forma"), + ("Toggle Transparency Grid", "Alternar grade de transparência"), + ("Database and Cache Folder", "Pasta de banco de dados e de cache"), + ("Adaptive Resolution", "Resolução adaptável"), + ("Adaptive Resolution Limit", "Limite de resolução adaptável"), + ("Alpha", "Alfa"), + ("Appearance", "Aparência"), + ("Appearance Mode", "Modo de aparência"), + ("Appearance Mode: {}", "Modo de aparência: {}"), + ("Appearance Settings…", "Configurações de aparência…"), + ("Audio", "Áudio"), + ("Auto-Save", "Auto-salvar"), + ("Cache Frames When Idle", "Armazenar quadros em cache quando ocioso"), + ("Dark", "Escuro"), + ("Default", "Padrão"), + ("Disk", "Disco"), + ("Disk Cache", "Cache do disco"), + ("Display", "Exibição"), + ("Draft", "Rascunho"), + ("EffectCraft on GitHub", "EffectCraft no GitHub"), + ("EffectCraft project", "Projeto do EffectCraft"), + ("Extended Viewer", "Visualizador estendido"), + ("Fast Draft", "Rascunho rápido"), + ("Footage", "Mídia"), + ("Freeze", "Congelar"), + ("Full", "Integral"), + ("Full Screen", "Tela inteira"), + ("General", "Geral"), + ("Gradient Editor", "Editor de gradiente"), + ("Half", "Metade"), + ("Horizontal", "Horizontal"), + ("Ignore", "Ignorar"), + ("Join the ArtCraft Discord", "Entre no Discord da ArtCraft"), + ("Language", "Idioma"), + ("Left", "Esquerda"), + ("Light", "Claro"), + ("Lines", "Linhas"), + ("Memory & CPU", "Memória e CPU"), + ("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"), + ("Off", "Desativado"), + ("Options", "Opções"), + ("Overlay", "Sobrepor"), + ("Premultiplied", "Pré-multiplicado"), + ("Preview Sample Rate", "Taxa de amostragem de pré-visualização"), + ("Previews", "Pré-visualizações"), + ("Project", "Projeto"), + ("RGB", "RGB"), + ("Right", "Direita"), + ("Roto Brush", "Roto Brush"), + ("Seconds", "Segundos"), + ("Stop", "Parar"), + ("Style", "Estilo"), + ("Sync with System", "Sincronizar com o sistema"), + ("Templates", "Modelos"), + ("Track", "Rastrear"), + ("UI Scale", "Escala da interface"), + ("Use Gradients", "Usar gradientes"), + ("Use Label Color for Layer Handles and Paths", "Usar a cor do rótulo nas alças de camada e nos caminhos"), + ("Use Label Color for Related Tabs", "Usar a cor do rótulo nas abas relacionadas"), + ("Vertical", "Vertical"), + ("Video", "Vídeo"), + ("channel", "canal"), + ("files", "arquivos"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -3651,6 +4041,7 @@ const CZECH: &[(&str, &str)] = &[ ("Overlay", "Překrytí"), ("Parameter Name:", "Název parametru:"), ("Parent pick whip: drag onto a layer", "Propojovací nástroj nadřazené vrstvy: přetáhněte na vrstvu"), + ("Track Matte pick whip: drag onto a layer", "Propojovací nástroj masky stopy: přetáhněte na vrstvu"), ("Pixel Aspect Ratio:", "Poměr stran pixelů:"), ("Playing on Video Preview", "Přehrávání ve videonáhledu"), ("Post-Render Action: {}", "Akce po vykreslení: {}"), @@ -4214,6 +4605,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Overlay", "Superposición"), ("Parameter Name:", "Nombre del parámetro:"), ("Parent pick whip: drag onto a layer", "Selector de capa padre: arrastra hasta una capa"), + ("Track Matte pick whip: drag onto a layer", "Selector de mate de seguimiento: arrastra hasta una capa"), ("Pixel Aspect Ratio:", "Relación de aspecto de píxel:"), ("Playing on Video Preview", "Reproduciendo en la previsualización de vídeo"), ("Post-Render Action: {}", "Acción posterior al procesamiento: {}"), @@ -4977,9 +5369,9 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Import Image Sequence", "გამოსახულებების თანმიმდევრობის იმპორტი"), ("Import PDF / Illustrator File", "PDF / Illustrator ფაილის იმპორტი"), ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), - ("Interpret Unlabeled Alpha As", "არამონიშნული ალფას ინტერპრეტაცია როგორც"), - ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), - ("Mercury GPU Acceleration ({})", "Mercury GPU აჩქარება ({})"), + ("Interpret Unlabeled Alpha As", "უცნობი ალფას ინტერპრეტაცია"), + ("Join the ArtCraft Discord", "შემოგვიერთდით Discord-ზე"), + ("Mercury GPU Acceleration ({})", "GPU აჩქარება ({})"), ("Mercury Software Only", "მხოლოდ Mercury Software"), ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), @@ -4998,7 +5390,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("EffectCraft on GitHub", "EffectCraft GitHub-ზე"), ("Motion graphics and visual effects · version {}", "მოძრავი გრაფიკა და ვიზუალური ეფექტები · ვერსია {}"), ("New Composition", "ახალი კომპოზიცია"), - ("Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), + ("Open Demo Project", "დემო-პროექტის გახსნა"), ("Import Footage…", "ვიდეომასალის იმპორტი…"), ("Filter recent files", "ბოლო ფაილების გაფილტვრა"), ("NAME", "სახელი"), @@ -5024,7 +5416,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("General", "ზოგადი"), ("Language", "ენა"), ("Levels of Undo", "დაბრუნების დონეები"), - ("Path Point and Handle Size", "ტრაექტორიის წერტილისა და სახელურის ზომა"), + ("Path Point and Handle Size", "წერტილისა და სახელურის ზომა"), ("Recent Projects Shown", "ნაჩვენები ბოლო პროექტები"), ("Options", "პარამეტრები"), ("Show Tool Tips", "მინიშნებების ჩვენება"), @@ -5063,7 +5455,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Automatically Save Projects", "პროექტების ავტომატური შენახვა"), ("Save Every", "შენახვა ყოველ"), ("Minutes", "წუთში"), - ("Maximum Project Versions", "პროექტის ვერსიების მაქსიმალური რაოდენობა"), + ("Maximum Project Versions", "ვერსიების მაქსიმალური რაოდენობა"), ("Auto-Save Location", "ავტოშენახვის მდებარეობა"), ("Next to Project", "პროექტის გვერდით"), ("Custom Location", "სხვა მდებარეობა"), @@ -5084,7 +5476,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Adaptive Resolution Limit", "ადაპტური გარჩევადობის ზღვარი"), ("Show Internal Wireframes", "შიდა კარკასების ჩვენება"), ("Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), - ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბირების ხარისხი"), + ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბის ხარისხი"), ("Faster", "უფრო სწრაფი"), ("More Accurate", "უფრო ზუსტი"), ("Display Color Space", "ეკრანის ფერთა სივრცე"), @@ -5115,7 +5507,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Latin", "ლათინური"), ("South Asian and Middle Eastern", "სამხრეთაზიური და ახლო აღმოსავლური"), ("Show Font Preview", "შრიფტის წინასწარი ნახვის ჩვენება"), - ("Number of Recent Fonts to Display", "საჩვენებელი ბოლო შრიფტების რაოდენობა"), + ("Number of Recent Fonts to Display", "ბოლო შრიფტების რაოდენობა"), ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), ("Still Footage", "უძრავი ვიდეომასალა"), ("Length of Composition", "კომპოზიციის სიგრძე"), @@ -5123,7 +5515,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Still Duration", "უძრავი კადრის ხანგრძლივობა"), ("Sequence Footage", "სეკვენციური ვიდეომასალა"), ("Report Missing Frames", "გამოტოვებული კადრების შეტყობინება"), - ("Automatically Reload Footage", "ვიდეომასალის ავტომატური ხელახლა ჩატვირთვა"), + ("Automatically Reload Footage", "ავტომატური ხელახლა ჩატვირთვა"), ("Off", "გამორთული"), ("Non-Sequence Footage", "არასეკვენციური ვიდეომასალა"), ("All Footage", "მთელი ვიდეომასალა"), @@ -5132,7 +5524,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Ignore", "უგულებელყოფა"), ("Straight", "პირდაპირი"), ("Premultiplied", "წინასწარ გამრავლებული"), - ("Default Drag Import As", "გადათრევით იმპორტი ნაგულისხმევად როგორც"), + ("Default Drag Import As", "გადათრევით იმპორტი როგორც"), ("Footage", "ვიდეომასალა"), ("Composition - Retain Layer Sizes", "კომპოზიცია - შრეების ზომების შენარჩუნებით"), ("Export", "ექსპორტი"), @@ -5145,7 +5537,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Append Bit Depth to File Name", "ბიტური სიღრმის ფაილის სახელზე დამატება"), ("Audio", "აუდიო"), ("Audio Hardware", "აუდიოაპარატურა"), - ("Preview Sample Rate", "წინასწარი ნახვის დისკრეტიზაციის სიხშირე"), + ("Preview Sample Rate", "ნახვის დისკრეტიზაციის სიხშირე"), ("Audio Output Mapping", "აუდიოს გამოტანის ასახვა"), ("Left", "მარცხენა"), ("Right", "მარჯვენა"), @@ -5220,7 +5612,7 @@ const GEORGIAN: &[(&str, &str)] = &[ /// exactly the Chinese catalogs' sources; the tests hold these to the same rules. const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Search project", "პროექტში ძიება"), - ("Select an item to see its details", "მონიშნეთ ელემენტი მისი დეტალების სანახავად"), + ("Select an item to see its details", "მონიშნეთ ელემენტი დეტალებისთვის"), ("Media Duration", "მედიის ხანგრძლივობა"), ("Frame Rate", "კადრების სიხშირე"), ("File Path", "ფაილის მისამართი"), @@ -5374,12 +5766,12 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Current Frame", "მიმდინარე კადრი"), ("Work Area", "სამუშაო არე"), ("Luminance", "სიკაშკაშე"), - ("Create Markers", "მარკერების შექმნა"), + ("Create Markers", "მარკერები"), ("Split Layers", "შრეების დაყოფა"), ("Split and Precompose", "დაყოფა და კომპოზიციაში მოთავსება"), ("CMX 3600 EDL (.edl, V1 only)", "CMX 3600 EDL (.edl, მხოლოდ V1)"), ("OMF (.omf, audio)", "OMF (.omf, აუდიო)"), - ("Layers Premiere can't show (text, shapes, effects…)", "შრეები, რომლებსაც Premiere ვერ აჩვენებს (ტექსტი, ფიგურები, ეფექტები…)"), + ("Layers Premiere can't show (text, shapes, effects…)", "Premiere-ში უხილავი შრეები"), ("All layers", "ყველა შრე"), ("None (leave them out)", "არცერთი (გამოტოვება)"), ("Nested sequences", "ჩადგმული სეკვენციები"), @@ -5439,6 +5831,7 @@ mod tests { ("menus/frontend.rs", include_str!("../menus/frontend.rs")), ("menus/invoke.rs", include_str!("../menus/invoke.rs")), ("menus/shortcuts.rs", include_str!("../menus/shortcuts.rs")), + ("state.rs", include_str!("../state.rs")), ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), @@ -5516,6 +5909,55 @@ mod tests { rows.iter().find(|(source, _)| *source == en).map_or(en, |(_, translated)| *translated) } + #[test] + fn the_toolbar_asks_the_catalog_for_the_tool_names() { + let header = include_str!("../header.rs"); + for call in ["crate::i18n::tr(tool.label())", "crate::i18n::tr(cur.label())"] { + assert!(header.contains(call), "header.rs no longer draws {call}"); + } + assert!( + header.contains("app.auto.add(&format!(\"tools.select.{tool:?}\"), row.rect, tool.label())"), + "the automation label must stay the English tool name" + ); + } + + #[test] + fn the_preview_panel_labels_are_translated() { + // The Preview panel draws these from a local closure, a check table and a roll tuple, none of + // which the call-shape scan sees; they had no rows at all. + for key in [ + "Shortcut", + "Range", + "Play From", + "Frame Rate", + "Resolution", + "Overlays", + "Layer Controls", + "Loop", + "Cache Before Playback", + "Pre-roll", + "Post-roll", + "If caching, play cached frames", + ] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has no {name} row"); + } + } + // A row is not a translation until the panel asks for it: the labels drawn one by one carry + // `tr(..)`, and the table and the roll tuple wrap the text they read at the call. + let source = include_str!("../panels/misc.rs"); + for literal in ["Shortcut", "Range", "Play From", "Frame Rate", "Resolution", "Full Screen", "If caching, play cached frames"] { + assert!(source.contains(&format!("tr(\"{literal}\")")), "{literal} is drawn without asking the catalog"); + } + for call in ["check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on)", "Align2::LEFT_CENTER, tr(text),"] { + assert!(source.contains(call), "{call} no longer asks the catalog"); + } + for table in ["\"Overlays\"", "\"Layer Controls\"", "\"Loop\"", "\"Cache Before Playback\"", "\"Pre-roll\"", "\"Post-roll\""] { + assert!(source.contains(table), "{table} is not in the panel"); + } + } + /// A row that translates nothing, repeats a source string, or changes the number of values the /// English source carries is a bug in the catalog itself. #[test] diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 949ea3771..385a6b1ca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -48,6 +48,8 @@ pub enum Icon { Speaker, Solo, Lock, + /// An open padlock: the shackle lifted clear of the body on the right. + LockOpen, Shy, Collapse, Quality, @@ -402,6 +404,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(8.0, 7.0, 3.0, 180.0, 360.0); pen.dot(8.0, 10.5, 1.0); } + LockOpen => { + pen.rect(3.5, 7.0, 12.5, 14.0); + pen.arc(8.0, 4.0, 3.0, 180.0, 360.0); + pen.line(&[(5.0, 4.0), (5.0, 7.0)]); + pen.dot(8.0, 10.5, 1.0); + } Shy => { pen.circle(8.0, 8.0, 5.8); pen.dot(6.0, 7.0, 0.8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcbfd49bf..0b259bfde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -184,6 +184,17 @@ pub struct Playback { /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, + /// Starting a preview with sound (#947): the playhead stays on the first frame while the + /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then + /// sound and picture start together from it. Without this the picture played on silently + /// while they rendered and the sound started at the later frame, so the opening was lost. + /// A stall later on still falls back to [`Playback::audio_held`]. + pub starting: bool, + /// When the preview started (seconds): [`Playback::starting`] gives up after `STARTUP_LIMIT`. + pub starting_since: f64, + /// When the first frame was cached while [`Playback::starting`] (seconds): the wait for the + /// frames ahead gives up `STARTUP_WAIT` after it. + pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 0610233de..0c334f74d 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -14,6 +14,12 @@ fn browse_menu_id() -> egui::Id { egui::Id::unique("menu-bar-browse") } +/// The id under which [`menu_bar`] remembers where it last saw the pointer (`None`: outside the +/// window). +fn last_pointer_id() -> egui::Id { + egui::Id::unique("menu-bar-last-pointer") +} + /// Draw the in-window menu bar (the engine's AE menu tree). /// /// The titles open on the press, not the release (native menu bars): while one is held the menus @@ -34,6 +40,12 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) { nav.set_browsing(); } + // Whether the pointer has moved since the last frame. Kept by position rather than egui's + // `delta`, which is zero for the first position after the pointer comes back into the window: + // coming back in over another title must switch to it too (#1290). + let pointer = ctx.pointer_hover_pos(); + let last = ctx.data_mut(|d| std::mem::replace(d.get_temp_mut_or(last_pointer_id(), None), pointer)); + let moved = pointer.is_some() && pointer != last; let mut drawn = 0; ui.horizontal_centered(|ui| { ui.add_space(6.0); @@ -53,7 +65,6 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { // then happened somewhere else), and toggle the menu. Hovering another title // switches to it once the pointer has moved: merely resting there must not take a // keyboard-driven menu over. - let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); // With the button down elsewhere nothing is hovered, so the title under the // pointer takes the highlight by hand: the hover-switch to it shows, one frame // later like egui's own hover (the open title keeps its open look). @@ -149,13 +160,18 @@ fn menu_nodes( // saved workspaces. let ws = app.ui.workspace.clone(); let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved)); + if nodes.is_empty() && let Some(e) = empty { ui.add_enabled(false, egui::Button::new((gutter(false), e))); } - for (i, e) in entries.iter().enumerate() { + for (i, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth); + continue; + }; let tip = match name.as_str() { "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(), diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 8fee44a72..d7b5d56ca 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -119,7 +119,8 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { } } if e.command == "edit.clear" && app.ui.focused == PanelKind::Project { - return app.session.is_enabled("project.delete"); + // An open text edit stands down: Clear must not delete the comp being typed into (#2227). + return app.session.state.text_edit.is_none() && app.session.is_enabled("project.delete"); } if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 047c576a5..5d65ffb8f 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -5,10 +5,15 @@ use super::{EffectcraftApp, PanelKind, Resolution, Value, json}; const COPY_COMMANDS: &[&str] = &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; -/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must -/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. -fn clipboard_note(s: &effectcraft_engine::Session) -> String { +/// Text for the system clipboard: copied keyframes and values as After Effects keyframe text, +/// for other applications (#1908), else a line naming what EffectCraft copied. The system +/// clipboard must hold something: with nothing on it, the windowing layer sends no paste event +/// for Ctrl+V. Paste in EffectCraft uses its own clipboard either way. +fn clipboard_text(s: &effectcraft_engine::Session) -> String { let st = &s.state; + if let Some(text) = &st.keyframe_text { + return text.clone(); + } let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); if st.clip_is_keys && !st.key_clipboard.is_empty() { n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") @@ -30,7 +35,7 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str if let Err(e) = &r { app.ui.status = e.clone(); } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { - ctx.copy_text(clipboard_note(&app.session)); + ctx.copy_text(clipboard_text(&app.session)); } let events = app.session.drain_events(); for (index, ev) in events.into_iter().enumerate() { @@ -320,6 +325,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "window.saveWorkspace" => { + crate::workspace_state::ensure_positions_ready(&app.ui)?; let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.store_saved_workspaces()?; @@ -336,6 +342,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu ); return Ok(Value::Null); }; + crate::workspace_state::ensure_positions_ready(&app.ui)?; app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.workspace = name.clone(); app.store_saved_workspaces()?; @@ -359,6 +366,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; let builtin = crate::dock::WORKSPACES.contains(&name.as_str()); if p.get("delete").and_then(Value::as_bool) == Some(true) { + crate::workspace_state::ensure_positions_ready(&app.ui)?; if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } @@ -373,6 +381,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if builtin { return Err(format!("`{name}` is a built-in workspace: save it as a new workspace instead")); } + crate::workspace_state::ensure_positions_ready(&app.ui)?; let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?; app.ui.saved_workspaces.insert(new.to_string(), dock); if app.ui.workspace == name { @@ -479,7 +488,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.home_templates = true; Value::Null } - "effect.manage" | "anim.browsePresets" => { + "effect.manage" => { + app.show_panel(PanelKind::EffectsPresets); + Value::Null + } + "anim.browsePresets" => { + app.ui.effects_search.clear(); + app.ui.effects_view.presets = true; + app.ui.effects_open.insert("presets".into()); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(crate::panels::effects_presets::REVEAL_PRESETS_ID), true)); app.show_panel(PanelKind::EffectsPresets); Value::Null } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b266623f3..23065067f 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; - let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ectx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); @@ -223,8 +232,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); } } - // Clear follows panel focus, including the Edit menu and keyboard shortcut. + // Clear follows panel focus, including the Edit menu and keyboard shortcut. An open text + // edit stands down: the user is working in a composition, and the Project-panel redirect + // would delete it without asking when nothing uses it (#2227). if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { + if app.session.state.text_edit.is_some() { + return Ok(Value::Null); + } // Through `invoke`, so deleting items that compositions use still asks first (M3.16). return invoke(app, ctx, "project.delete", json!({})); } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index fac8f0d53..85d0fb193 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -131,24 +131,35 @@ impl NativeMenu { /// otherwise follows the project clipboard and would be greyed out with nothing copied there, /// while the field can paste. pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { - fn enable(nodes: &mut [NativeNode]) { + fn enable(app: &EffectcraftApp, nodes: &mut [NativeNode]) { for node in nodes { match node { - NativeNode::Item(item) if edits_text(&item.command) => item.enabled = true, - NativeNode::Submenu { children, .. } => enable(children), + NativeNode::Item(item) if edits_text(&item.command) => { + item.enabled = true; + let source = match item.command.as_str() { + "edit.undo" => Some("Undo"), + "edit.redo" => Some("Redo"), + _ => None, + }; + if let Some(source) = source { + item.label = crate::i18n::label(app, &item.command, source).into(); + } + } + NativeNode::Submenu { children, .. } => enable(app, children), _ => {} } } } let mut menu = build(app); if text_field_window(ctx).is_some() { - enable(&mut menu.menus); + enable(app, &mut menu.menus); } menu } /// Build the native menu spec for the app's current state. pub fn build(app: &EffectcraftApp) -> NativeMenu { + /// Append the native nodes of `nodes` to `out`; item ids start with `path`. fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec) { let mut services_added = false; for (i, n) in nodes.iter().enumerate() { @@ -159,8 +170,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { // Recent projects / footage / presets, undo history, shortcut slots, viewers, // saved workspaces. let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); + if nodes.is_empty() && let Some(e) = empty { out.push(NativeNode::Item(NativeItem { @@ -174,7 +185,12 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { checked: None, })); } - for (k, e) in entries.iter().enumerate() { + for (k, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out); + continue; + }; out.push(NativeNode::Item(NativeItem { id: format!("{id}.d{k}"), label: e.label.clone(), @@ -736,4 +752,45 @@ mod tests { assert!(edits_text("edit.copy") && edits_text("edit.paste")); assert!(!edits_text("layer.newNull")); } + /// #2459: while a text field has the keyboard the Edit menu's Undo and Redo act on the field and + /// read plain "Undo" / "Redo"; the project history labels come back once focus leaves. + #[test] + fn a_focused_text_field_gets_plain_undo_and_redo_labels() { + fn label(menu: &NativeMenu, id: &str) -> String { + menu.items().into_iter().find(|i| i.command == id).map(|i| i.label.to_string()).unwrap_or_default() + } + for (language, undo, redo) in [("en", "Undo", "Redo"), ("ja", "取り消し", "やり直し")] { + let mut app = app(); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + let ctx = egui::Context::default(); + let history = label(&build_now(&app, &ctx), "edit.undo"); + let redo_history = label(&build_now(&app, &ctx), "edit.redo"); + assert!(history.starts_with(undo) && history != undo, "{history}"); + assert!(redo_history.starts_with(redo) && redo_history != redo, "{redo_history}"); + let mut text = String::new(); + let mut id = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = ui.text_edit_singleline(&mut text); + r.request_focus(); + id = Some(r.id); + }); + out.textures_delta.clear(); + } + assert!(text_field_window(&ctx).is_some()); + let typing = build_now(&app, &ctx); + assert_eq!(label(&typing, "edit.undo"), undo); + assert_eq!(label(&typing, "edit.redo"), redo); + assert!(typing.items().into_iter().any(|i| i.command == "edit.undo" && i.enabled)); + assert!(typing.items().into_iter().any(|i| i.command == "edit.redo" && i.enabled)); + ctx.memory_mut(|m| m.surrender_focus(id.unwrap_or(egui::Id::NULL))); + assert!(text_field_window(&ctx).is_none()); + let after = build_now(&app, &ctx); + assert_eq!(label(&after, "edit.undo"), history); + assert_eq!(label(&after, "edit.redo"), redo_history); + } + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a8fd2a54..d386d9784 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -175,6 +175,56 @@ pub(crate) fn space_hand(ctx: &egui::Context) -> bool { ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input() } +/// What a scroll gesture over a viewer (Composition, Layer) does this frame. +pub(crate) struct ViewerScroll { + /// Points to pan by: a two-finger trackpad scroll. + pub pan: egui::Vec2, + /// Factor to zoom by around the pointer: a pinch, Ctrl/Cmd+scroll, a mouse wheel, or + /// Alt + a trackpad scroll. + pub zoom: f32, +} + +/// Splits the frame's scroll input into a viewer pan and a zoom: a mouse wheel zooms and a +/// trackpad's two-finger scroll pans. +/// +/// Where the platform names the device (`MouseWheelSource`, macOS), that decides. Elsewhere a +/// wheel scrolls in lines, but in a browser and with some high-resolution wheels it scrolls in +/// points like a trackpad does, so the unit alone doesn't tell them apart. A scroll in points is +/// a trackpad's once it has shown what a wheel can't do: a touch phase, movement on both axes at +/// once, or a pinch. That holds until a wheel scrolls in lines. Until then the vertical part +/// zooms; the horizontal part always pans. +pub(crate) fn viewer_scroll(ctx: &egui::Context) -> ViewerScroll { + let id = egui::Id::unique("viewer-scroll-trackpad"); + // The scroll is smoothed over the frames after its event, so the device is remembered. + let was: bool = ctx.data(|d| d.get_temp(id)).unwrap_or(false); + let trackpad = ctx.input(|i| { + i.events.iter().fold(was, |t, e| match e { + egui::Event::MouseWheel { source: egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum, .. } => true, + egui::Event::MouseWheel { source: egui::MouseWheelSource::Wheel, .. } => false, + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase, modifiers, .. } => { + // Shift turns a wheel's scroll sideways, which is still one axis. + t || *phase == egui::TouchPhase::Start || (delta.x != 0.0 && delta.y != 0.0 && !modifiers.shift) + } + egui::Event::MouseWheel { .. } => false, + egui::Event::Zoom(_) => true, + _ => t, + }) + }); + if trackpad != was { + ctx.data_mut(|d| d.insert_temp(id, trackpad)); + } + let (scroll, pinch, alt) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers.alt)); + let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; + let scroll = if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO }; + if trackpad && !alt { + ViewerScroll { pan: scroll, zoom: pinch } + } else if alt { + ViewerScroll { pan: egui::Vec2::ZERO, zoom: pinch * (scroll.y / 300.0).exp() } + } else { + ViewerScroll { pan: egui::vec2(scroll.x, 0.0), zoom: pinch * (scroll.y / 300.0).exp() } + } +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { PanelKind::Composition => viewers::show(app, ui, 0, rect), diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 3f7cda26d..0d07afd94 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -246,36 +246,18 @@ fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` -/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. -pub fn show( +/// The controls of one tab. +fn tab_body( ui: &mut egui::Ui, + tab: Tab, d: &mut CompDraft, t: &Tokens, auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, -) -> Option { - let mut out = None; - ui.horizontal(|ui| { - ui.label(tr("Composition Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); - crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); - auto.add("dialog.comp.name", r.rect, "Composition Name"); - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); - auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); - if r.clicked() { - d.tab = tab; - } - } - }); - ui.separator(); +) { let rate = FrameRate::from_f64(d.fps); - match d.tab { + match tab { Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); @@ -327,7 +309,7 @@ pub fn show( let (ow, oh) = (d.width, d.height); let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); - let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); + let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]); let r = ui.checkbox(&mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { @@ -485,6 +467,54 @@ pub fn show( }); } } +} + +/// The height `tab` would take at `top`, from laying it out unseen: it follows the font, the +/// language and the scale. Nothing it registers or changes is kept. +fn tab_height(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> f32 { + let rect = egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), 0.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt("comp-tab-measure").invisible()); + tab_body(&mut child, tab, &mut d.clone(), t, &mut Registry::default(), custom, &mut Vec::new()); + child.cursor().top() - top +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) -> Option { + let mut out = None; + ui.horizontal(|ui| { + ui.label(tr("Composition Name:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); + auto.add("dialog.comp.name", r.rect, "Composition Name"); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { + let r = ui.selectable_label(d.tab == tab, label); + auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); + if r.clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + // Every tab takes the height of the tallest one, so Cancel and OK stay put between tabs. + let body_top = ui.cursor().top(); + tab_body(ui, d.tab, d, t, auto, custom, cmds); + let shown = ui.cursor().top() - body_top; + let tallest = [Tab::Basic, Tab::Advanced, Tab::Renderer] + .into_iter() + .filter(|tab| *tab != d.tab) + .fold(shown, |h, tab| h.max(tab_height(ui, tab, d, t, custom, body_top))); + ui.add_space((tallest - shown).max(0.0)); ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..12f3e0057 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: app.session.time(), + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 05fea44c9..249c8462b 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -88,6 +88,11 @@ struct PropCx<'a> { } impl PropCx<'_> { + /// The tokens of the value fields: red while an expression drives the parameter (#1306). + fn value_tokens(&self) -> Tokens { + self.t.for_value(self.prop.has_expression()) + } + fn set(&self, actions: &mut Actions, v: serde_json::Value) { let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) @@ -243,8 +248,8 @@ fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + let PropCx { uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens()); app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -257,7 +262,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let PropCx { uid, cy, vx, prop, .. } = *cx; let (min, max, dec) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), _ => (-1e9, 1e9, 1), @@ -273,7 +278,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, - &t, + &cx.value_tokens(), ); app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); if let Some(nv) = nv { @@ -290,6 +295,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; let c = value.components(); let n = shown_dims(layer, prop, value); + let vt = cx.value_tokens(); let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); @@ -310,7 +316,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, - &t, + &vt, ); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); dims.push(vr); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 380d1d869..44c77ec22 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -17,6 +17,7 @@ use crate::{EffectcraftApp, widgets}; const ROW_H: f32 = 21.0; const RECENT_MAX: usize = 8; +pub(crate) const REVEAL_PRESETS_ID: &str = "fx-reveal-presets"; /// A user animation preset file. #[derive(Clone, Debug, PartialEq)] @@ -157,6 +158,24 @@ fn folder_row( open } +/// Number of visible rows before Animation Presets in the current panel state. +fn rows_before_presets(favorites: usize, recent: usize, searching: bool, favorites_open: bool, recent_open: bool) -> usize { + let mut rows = 0usize; + if favorites > 0 { + rows = rows.saturating_add(1); + if searching || favorites_open { + rows = rows.saturating_add(favorites); + } + } + if !searching && recent > 0 { + rows = rows.saturating_add(1); + if recent_open { + rows = rows.saturating_add(recent); + } + } + rows +} + /// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge). #[allow(clippy::too_many_arguments)] fn effect_row( @@ -364,7 +383,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let view = app.ui.effects_view.clone(); let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); let lp = p.with_clip_rect(list); - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); + let mut scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); let query = q.trim().to_lowercase(); let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; @@ -378,6 +397,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Favorites and Recently Used. let favs: Vec<_> = app.session.prefs.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let reveal_presets = ctx.data_mut(|data| data.remove_temp::(egui::Id::unique(REVEAL_PRESETS_ID))).unwrap_or(false); + if reveal_presets { + // These rows come before Animation Presets. Scroll past expanded lists so the section is + // visible even when Favorites or Recently Used is long. + let preceding_rows = + rows_before_presets(favs.len(), recent.len(), searching, app.ui.effects_open.contains("favorites"), app.ui.effects_open.contains("recent")); + scroll.offset = ROW_H * preceding_rows as f32; + y = list.min.y - scroll.offset; + } if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) { let mut favs = favs; favs.sort_by_key(|e| e.name); @@ -385,7 +414,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply); } } - let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) { for e in recent { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 157288b94..6d742dc4e 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[ "ease", "easeIn", "easeOut", + "bezier", "clamp", "random", "gaussRandom", diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index fe10f4ddb..e132a42d1 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -241,7 +241,7 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn to_color32(c: [f32; 4]) -> Color32 { +pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) } diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 5fb4d703c..8e4f3502f 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -104,7 +104,16 @@ fn render_tex( match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); @@ -310,13 +319,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); - // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any - // tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out). + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer and a + // two-finger trackpad scroll pans with any tool; the Hand tool or Space-drag pans and the + // Zoom tool clicks zoom in (Alt: out). let hovered = ui.rect_contains_pointer(area); if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) { - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.is_finite() && scroll.abs() > 0.1 { - zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp()); + let scroll = super::viewer_scroll(&ctx); + if (scroll.zoom - 1.0).abs() > 3e-4 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * scroll.zoom); + } else if scroll.pan != egui::Vec2::ZERO { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + scroll.pan.x, pan.y + scroll.pan.y]; } } // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 14e774f67..cc08f223d 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -285,8 +285,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: let x = tm.x(comp_t); let col = marker_color(&t, m.label); let h = (y1 - y0).min(10.0); - if m.duration > Tick(0) { - let xe = tm.x(comp_t + m.duration.seconds()); + let end = (m.duration > Tick(0)).then(|| tm.x(comp_t + m.duration.seconds())); + if let Some(xe) = end { let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0)); p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 })); if m.protected { @@ -300,7 +300,20 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: } p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { - p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); + // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field + // colours, so it follows Dark, Darker and Light. + let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); + let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0); + // A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead. + if let Some(xe) = end + && xe > box_x + && xe < box_x + box_w + { + box_x = xe + 3.0; + } + let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0))); + p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); } let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1)); let id = egui::Id::unique(("marker", r.layer, r.index)); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfa9a42e0..65e188c90 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -61,7 +61,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = y + 46.0; // Shortcut. - label(yy, "Shortcut"); + label(yy, tr("Shortcut")); let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -91,14 +91,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = if i < 4 { i / 2 } else { i - 2 }; let cx = x0 + col as f32 * 110.0 - 2.0; let cy = yy + row as f32 * 22.0; - if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + if check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on) { changes.push(json!({"shortcut": current.id(), key: !on})); } } yy += 4.0 * 22.0 + 6.0; // Range (and the pre/post-roll of Play Around Current Time). - label(yy, "Range"); + label(yy, tr("Range")); let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; if let Some(i) = dropdown_row( @@ -116,7 +116,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if o.range == PreviewRange::AroundCurrentTime { for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { let lx = xv + k as f32 * 100.0; - p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + p.text(pos2(lx, yy), Align2::LEFT_CENTER, tr(text), Tokens::ui(11.5), t.text_dim); let (r, nv, _) = widgets::hot_number_at( ui, pos2(lx + 52.0, yy - 9.0), @@ -134,7 +134,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Play From. - label(yy, "Play From"); + label(yy, tr("Play From")); let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -151,7 +151,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame Rate, Skip, Resolution. let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); - label(yy, "Frame Rate"); + label(yy, tr("Frame Rate")); const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); let mut rates = vec![auto.clone()]; @@ -180,7 +180,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); } yy += 24.0; - label(yy, "Resolution"); + label(yy, tr("Resolution")); let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; if let Some(i) = dropdown_row( @@ -209,7 +209,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } yy += 24.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", tr("Full Screen"), o.full_screen) { changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); } yy += 24.0; @@ -217,7 +217,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // What stopping does. p.text(pos2(x0, yy), Align2::LEFT_CENTER, tr_args("On ({}) stop:", &[&(current.label())]), Tokens::ui(11.5), t.text_dim); yy += 20.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", tr("If caching, play cached frames"), o.play_cached_frames) { changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); } yy += 22.0; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c35108937..a7563a6ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let project = app.session.project.clone(); let expr = app.session.expr.clone(); let now = app.session.time(); - let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &project, + comp_id: cid, + comp: &comp, + time: now, + expr: expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); @@ -200,6 +209,8 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { #[allow(clippy::too_many_arguments)] fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = t.for_value(pr.has_expression()); let cy = r.center().y; let uid = pr.uid; let lid = layer.id.0; @@ -276,7 +287,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match &value { Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { let (_, deg) = super::fx_widgets::split_angle(*v); - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &vt); app.auto.add(&format!("{base}.value"), dr, &pr.name); app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); if let Some(nv) = nv { @@ -291,7 +302,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid)), *v, &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value"), vr, &pr.name); if let Some(nv) = nv { @@ -323,7 +334,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 0d9eefe45..ca3c56ac5 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -69,7 +69,16 @@ pub fn draw_overlay( let ov = match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 242626272..d12309b02 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -261,20 +261,22 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } } +/// A draw-list colour (`None` is the theme colour). Script colours are display (sRGB) values +/// in 0..1, as in After Effects, not linear light: `[43/255, 43/255, 43/255, 1]` is RGB 43. +fn draw_color(c: &Option<[f32; 4]>, theme: Color32) -> Color32 { + c.map_or(theme, super::gradient_editor::to_color32) +} + /// Paint an onDraw draw list in `rect` (control coordinates → screen). fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) { let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - let col = |c: &Option<[f32; 4]>, theme: Color32| match c { - Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), - None => theme, - }; let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); for op in ops { match op { DrawOp::Fill { color, path } => { // Any path shape (concave, self-intersecting, with holes): non-zero winding // triangles, not egui's convex-only polygon fill. - let c = col(color, t.field_bg); + let c = draw_color(color, t.field_bg); let mut mesh = egui::Mesh::default(); for tri in PathSeg::fill_triangles(path, 48, false) { let base = mesh.vertices.len() as u32; @@ -288,14 +290,14 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: & } } DrawOp::Stroke { color, width, path } => { - let s = Stroke::new(*width as f32, col(color, t.text)); + let s = Stroke::new(*width as f32, draw_color(color, t.text)); for (pts, closed) in PathSeg::polylines(path, 48) { let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); } } DrawOp::Text { text, color, x, y, size, .. } => { - p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), draw_color(color, t.text)); } DrawOp::Image { image, x, y, w, h } => { let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services)); @@ -595,3 +597,47 @@ pub fn sync_panels(app: &mut EffectcraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Script colours are sRGB, not linear: `#2B2B2B` drew as RGB 114 when read as linear light. + #[test] + fn draw_colours_are_srgb() { + let v = 43.0 / 255.0; + assert_eq!(draw_color(&Some([v, v, v, 1.0]), Color32::RED), Color32::from_rgb(43, 43, 43)); + let orange = [242.0 / 255.0, 140.0 / 255.0, 40.0 / 255.0, 1.0]; + assert_eq!(draw_color(&Some(orange), Color32::RED), Color32::from_rgb(242, 140, 40)); + assert_eq!(draw_color(&None, Color32::RED), Color32::RED, "no colour is the theme colour"); + } + + /// fillPath, strokePath and drawString all paint the script's colour as sRGB. + #[test] + fn paint_draw_paints_srgb_colours() { + let v = 43.0 / 255.0; + let color = Some([v, v, v, 1.0]); + let path = vec![PathSeg::R { x: 0.0, y: 0.0, w: 10.0, h: 10.0 }]; + let ops = [ + DrawOp::Fill { color, path: path.clone() }, + DrawOp::Stroke { color, width: 1.0, path }, + DrawOp::Text { text: "Hi".into(), color, x: 0.0, y: 0.0, size: 12.0, style: String::new() }, + ]; + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| { + paint_draw(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 50.0)), &ops, &t, &effectcraft_engine::FsServices); + }); + out.textures_delta.clear(); + let want = Color32::from_rgb(43, 43, 43); + let (mut fill, mut stroke, mut text) = (false, false, false); + for s in out.shapes { + match s.shape { + egui::Shape::Mesh(m) => fill |= m.vertices.iter().all(|v| v.color == want), + egui::Shape::Path(p) => stroke |= matches!(p.stroke.color, egui::epaint::ColorMode::Solid(c) if c == want), + egui::Shape::Text(t) => text |= t.fallback_color == want, + _ => {} + } + } + assert!(fill && stroke && text, "fill {fill}, stroke {stroke}, text {text}"); + } +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 692f48236..f4dd89bfc 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -803,9 +803,9 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if m < 1 { "less than a minute ago".to_string() } else if m < 120 { - format!("{m} minutes ago") + tr_args("{} minutes ago", &[&m]) } else { - format!("{} hours ago", m / 60) + tr_args("{} hours ago", &[&(m / 60)]) } }); ui.label(tr_args("Latest auto-save: {}", &[&a])); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8a8e604e2..2ded3b3e3 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .iter() .filter_map(|id| comp.layer(*id)) .find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?; - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None }) } @@ -762,10 +771,11 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == selected, &r.display)); let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height())); + let name = drawn_font_name(ui.ctx(), r); ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( label_rect.left_center(), Align2::LEFT_CENTER, - &r.display, + name, Tokens::ui(12.0), t.text, ); @@ -799,7 +809,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: if resp.clicked() { chosen = Some(r.family.clone()); } - resp.on_hover_text(&r.display); + resp.on_hover_text(name); } chosen }; @@ -945,6 +955,13 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token } } +/// The name a font menu row draws: the font's own-language name when the interface fonts have a +/// glyph for each of its characters, else its English family name. A Simplified Chinese name +/// such as 微软雅黑 can have characters that the Han face of an English interface lacks. +fn drawn_font_name<'a>(ctx: &egui::Context, row: &'a effectcraft_engine::FontRow) -> &'a str { + if ctx.fonts_mut(|f| f.has_glyphs(&Tokens::ui(12.0), &row.display)) { &row.display } else { &row.family } +} + /// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, /// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; /// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview @@ -1003,4 +1020,17 @@ mod tests { assert!(fit_preview(&[], room, baseline).is_none()); assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); } + + /// A font menu row draws the font's own-language name when the interface fonts have its + /// glyphs, and the English family name when they do not. + #[test] + fn font_menu_rows_draw_a_name_the_interface_fonts_have() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(Default::default()), "en"); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let row = |display: &str| effectcraft_engine::FontRow { family: "Menu Name Test".into(), display: display.into(), recent: false, variable: false }; + // U+F1234 is in a private use plane that no interface font maps. + assert_eq!(drawn_font_name(&ctx, &row("Menu \u{F1234}")), "Menu Name Test"); + assert_eq!(drawn_font_name(&ctx, &row("Nom du menu")), "Nom du menu"); + } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..80faef91a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -158,11 +158,13 @@ fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, } /// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the -/// layer's parent pick whip, an expression's and a property's (Parent & Link column). +/// layer's parent pick whip, an expression's, a property's (Parent & Link column) and the layer's +/// track matte pick whip. type PickWhip = (u8, u64, u64, Pos2); const WHIP_PARENT: u8 = 0; const WHIP_EXPR: u8 = 1; const WHIP_PROP: u8 = 2; +const WHIP_MATTE: u8 = 3; /// The `egui::Id` under which a pick-whip drag in progress ([`PickWhip`]) is kept during the /// drag. @@ -170,6 +172,11 @@ fn pick_whip_id() -> egui::Id { egui::Id::unique("tl-pickwhip") } +/// The layers a layer pick whip (parent, track matte) links, taken as its drag starts. +fn whip_layers_id() -> egui::Id { + egui::Id::unique("tl-pickwhip-layers") +} + /// The automation id, label and hover hint of a pick whip. struct WhipText<'a> { auto: &'a str, @@ -193,7 +200,15 @@ fn pick_whip( app.auto.add(auto, rect, label); if resp.drag_started() { let state: PickWhip = (kind, layer, prop, rect.center()); - ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state)); + let selected = &app.session.state.selected_layers; + let layers = match (kind, app.session.active_comp()) { + (WHIP_MATTE, Some(comp)) => video_targets(comp, LayerId(layer), selected), + _ => edited_layers(LayerId(layer), selected), + }; + ui.ctx().data_mut(|d| { + d.insert_temp(pick_whip_id(), state); + d.insert_temp(whip_layers_id(), layers); + }); } resp } @@ -795,12 +810,11 @@ fn display_name(app: &EffectcraftApp, l: &Layer) -> String { /// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group /// brings its whole contents. fn search_rows(l: &Layer, q: &str) -> Vec { - /// The row for a group in search results: depth, its effect's enabled flag, and the eye - /// switch for layer-style groups (not Blending Options). + /// The row for a group in search results: depth, its effect's enabled flag, and its eye + /// switch ([`eye_of`]). fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row { let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); - let eye = - (parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled); + let eye = eye_of(parent, g); Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } } } /// Every visible child of `g` at `depth`, groups recursed into whole — a matching group @@ -1235,6 +1249,14 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo /// in `open`, text animators list their selectors and properties directly (as in AE), and a /// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items /// list directly under the group. +/// The eye switch of group `g` inside `parent`, as in After Effects: layer styles (not Blending +/// Options) and every item in a shape layer's Contents (#959), which the renderer skips when off. +fn eye_of(parent: &PropGroup, g: &PropGroup) -> Option { + let style = parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING; + let shape_item = parent.match_id == "contents" && g.match_id != "transform"; + (style || shape_item).then_some(g.enabled) +} + fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -1250,9 +1272,7 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); - // Layer style groups have eye switches (not Blending Options). - let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING) - .then_some(sg.enabled); + let eye = eye_of(g, sg); rows.push(Row { layer: l.id, depth, @@ -1293,6 +1313,13 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon } /// Layers being dragged to a new place in the stack (timeline rows). +#[derive(Clone)] +struct LayerDrag { + comp: ItemId, + owner: egui::Id, + layers: Vec, +} + fn layer_drag_id() -> egui::Id { egui::Id::unique("tl-layer-drag") } @@ -1388,8 +1415,9 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } -/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): -/// the selection when the row is selected, else that layer alone, as in After Effects. +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode, +/// its parent or track matte, moving its bar): the selection when the row is selected, else that +/// layer alone, as in After Effects (#1260). fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } } @@ -1415,9 +1443,10 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() } -/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) -/// acts on: [`edited_layers`] that have video. -fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { +/// The layers a Modes column edit (blending mode, Preserve Transparency) or a Track Matte edit +/// on `layer`'s row acts on: [`edited_layers`] that have video. Selected nulls, cameras and +/// lights (no Modes or Track Matte on their rows) are left out. +fn video_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() } @@ -1502,10 +1531,18 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option egui::Id { - egui::Id::unique("tl-layer-drop") +/// Resolve a visible insertion boundary through the full outline, including offscreen layers. +fn layer_drop_target(rows: &[Row], visible: &[(Rect, LayerId)], at: Option, moving: &[u64]) -> Option { + let (anchor, after) = match at { + Some(i) => (visible.get(i)?.1, false), + None => (visible.last()?.1, true), + }; + rows.iter() + .filter(|row| matches!(row.kind, RowKind::Layer)) + .skip_while(|row| row.layer != anchor) + .skip(usize::from(after)) + .find(|row| !moving.contains(&row.layer.0)) + .map(|row| row.layer) } /// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` @@ -1520,7 +1557,7 @@ fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { /// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and /// the item wouldn't put the composition inside itself. -fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { +pub(crate) fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { app.session.project.item(item).is_some_and(replaceable_item) && !app.session.project.comp_contains(item, cid) && comp @@ -1828,7 +1865,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: g.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; draw_rows(app, ui, g, &ectx); @@ -2903,13 +2949,10 @@ fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let is_sel = rc.is_sel; // Locked layers can't be selected or moved (unlock them with the lock switch). let locked = layer.switches.locked; - if row_resp.drag_started() && !locked { + if row_resp.drag_started_by(egui::PointerButton::Primary) && !locked { // The selected layers move together; an unselected row moves alone. let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving)); - } - if row_resp.drag_stopped() { - ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true)); + ctx.data_mut(|d| d.insert_temp(layer_drag_id(), LayerDrag { comp: cid, owner: row_resp.id, layers: moving })); } app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); if row_resp.clicked() && !locked { @@ -2996,7 +3039,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let opts: Vec = BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, selected), "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": video_targets(&comp, layer.id, selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); @@ -3006,7 +3049,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: actions.push(( "layer.setSwitch".into(), json!({ - "layers": mode_targets(&comp, layer.id, selected), + "layers": video_targets(&comp, layer.id, selected), "switch": "preserveTransparency", "value": !layer.preserve_transparency }), @@ -3020,10 +3063,25 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let comp = g.tl.comp.clone(); let cw = &g.tl.cw; let actions = &mut g.tl.actions; + let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; - // Track matte: layer picker + kind. - let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); + // Track matte: a pick whip, then the layer picker with the mode. Like the parent, they set + // the matte of every selected layer when the row is selected (#1260). + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.trkmat + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_MATTE, layer.id.0, 0), + WhipText { + auto: &format!("timeline.layer.{}.trackMattePickWhip", layer.id.0), + label: "Track Matte pick whip", + hint: crate::i18n::tr("Track Matte pick whip: drag onto a layer"), + }, + ); + let mr = Rect::from_min_size(pos2(cw.trkmat + 20.0, cy - 9.0), vec2(88.0, 18.0)); let label = match layer.track_matte { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), @@ -3053,7 +3111,7 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { let ids: Vec = candidates.iter().map(|l| l.id).collect(); - if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&video_targets(&comp, layer.id, selected), &ids, i, o)) { actions.push(a); } } @@ -3070,6 +3128,7 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let cw = &g.tl.cw; let actions = &mut g.tl.actions; let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; pick_whip( @@ -3101,8 +3160,10 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc } } if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { - let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; - actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + let par = cands.get(i.wrapping_sub(2)).map(|l| l.id.0); + // (The new parent itself, if selected, stays as it is.) + let layers: Vec = edited_layers(layer.id, selected).into_iter().filter(|l| Some(*l) != par).collect(); + actions.push(("layer.setParent".into(), json!({"layers": layers, "parent": par}))); } } @@ -3270,7 +3331,6 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let selected = &g.selected; let ui_actions = &mut g.ui_actions; let layer = rc.layer; - let is_sel = rc.is_sel; let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if kind == "move" && quick { @@ -3286,7 +3346,7 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let d = frames as f64 * fd; // Moving or trimming a selected layer acts on the whole // selection; each layer trims from its own in/out point. - let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; + let targets = json!(edited_layers(layer.id, selected)); let params = match kind { "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), @@ -3388,11 +3448,16 @@ fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: & app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name); } if let Some(en) = eye { - // Layer style eye switch in the A/V column, like AE. + // A layer style's or shape item's eye switch in the A/V column, like AE. let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0)); lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() { - actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); + let style = layer.props.sub(effectcraft_engine::project::styles::GROUP).is_some_and(|s| s.find_group(*uid).is_some()); + actions.push(if style { + ("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})) + } else { + ("prop.setGroupEnabled".into(), json!({"layer": layer.id.0, "prop": uid, "value": !*en})) + }); } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } @@ -4238,6 +4303,7 @@ fn pick_whip_drag(g: &mut Rw) { let t = g.tl.t; let graph_x0 = g.tl.graph_x0; let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; let dim_hits = &mut g.dim_hits; @@ -4250,7 +4316,7 @@ fn pick_whip_drag(g: &mut Rw) { let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); // Shown this pass or the last one (Effect Controls may draw after the Timeline). let pass = ctx.cumulative_pass_nr(); - let elsewhere = (target.is_none() && kind != WHIP_PARENT) + let elsewhere = (target.is_none() && !matches!(kind, WHIP_PARENT | WHIP_MATTE)) .then(|| ctx.data(|d| d.get_temp::(whip_targets_id()))) .flatten() .filter(|(p, _)| p.saturating_add(1) >= pass) @@ -4261,10 +4327,13 @@ fn pick_whip_drag(g: &mut Rw) { fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); } if ctx.input(|i| !i.pointer.any_down()) { - ctx.data_mut(|d| { + let whip_layers = ctx.data_mut(|d| { d.remove::(pick_whip_id()); d.remove::(whip_targets_id()); + d.remove_temp::>(whip_layers_id()) }); + // The layers a layer pick whip links, less the one it was dropped on. + let linked = |onto: u64| -> Vec { whip_layers.clone().unwrap_or_else(|| vec![src_layer]).into_iter().filter(|l| *l != onto).collect() }; let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); // The property dropped on, and the value (dimension) if on one of them: `position[0]`. let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr)); @@ -4277,7 +4346,11 @@ fn pick_whip_drag(g: &mut Rw) { }; match (kind, target, prop_target) { (WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => { - actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + actions.push(("layer.setParent".into(), json!({"layers": linked(row.layer.0), "parent": row.layer.0}))); + } + // Each layer keeps its own matte mode. + (WHIP_MATTE, Some((_, row)), _) if row.layer.0 != src_layer && comp.layer(row.layer).is_some_and(|l| l.source.is_av()) => { + actions.push(("layer.setTrackMatte".into(), json!({"layers": linked(row.layer.0), "matte": row.layer.0, "kind": "preserve"}))); } // The property pick whip replaces the expression with the reference (or adds one), // as the expression pick whip does when its expression isn't being edited. @@ -4286,6 +4359,10 @@ fn pick_whip_drag(g: &mut Rw) { if let Some(d) = d { params["target"]["dimension"] = json!(d); } + // Alt held as it drops: an effect's properties by their index (#1072). + if ctx.input(|i| i.modifiers.alt) { + params["byIndex"] = json!(true); + } if let Some((text, [a, b])) = into { // Into the expression being edited, which goes on being edited. params["expression"] = json!(text); @@ -4351,6 +4428,7 @@ fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) { /// Layer reorder drag: a line where the layers land. fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let cid = g.tl.cid; let t = g.tl.t; let rect = g.tl.rect; let graph_x0 = g.tl.graph_x0; @@ -4360,16 +4438,29 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { let p = g.tl.p.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; - if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) { - let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false); + // The origin row can scroll out of view, so finish the gesture here, independently of + // its Response. Disabled UI, Escape or lost ownership cancels; only a primary release drops layers. + if let Some(drag) = ctx.data(|d| d.get_temp::(layer_drag_id())) { + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let valid = ui.is_enabled() + && drag.comp == cid + && drag.layers.iter().all(|id| comp.layer(LayerId(*id)).is_some()) + && !escape + && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(layer_drag_id())); + } + let moving = &drag.layers; let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); if let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) + && valid && !layer_rows.is_empty() { let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y); - let target = at.and_then(|i| layer_rows[i..].iter().find(|(_, l)| !moving.contains(&l.0)).map(|(_, l)| *l)); + let target = layer_drop_target(&g.tl.rows, &layer_rows, at, moving); if dropped { - ctx.data_mut(|d| d.remove::>(layer_drag_id())); // Only when the order changes (no empty undo step for a drop in place). let mut order: Vec = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect(); let at = target.and_then(|t| order.iter().position(|id| *id == t.0)).unwrap_or(order.len()); @@ -4385,8 +4476,6 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { ctx.set_cursor_icon(egui::CursorIcon::Grabbing); p.with_clip_rect(rows_rect).line_segment([pos2(rect.min.x, line_y), pos2(graph_x0, line_y)], Stroke::new(2.0, t.accent)); } - } else if dropped { - ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer @@ -5103,19 +5192,21 @@ fn text_anim_popups( } } -/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track -/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte -/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). -fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { +/// The command for the Track Matte menu item `i` (`choice`) of a row, setting `layers`' mattes, +/// whose menu lists "No Track Matte", a separator, the matte `candidates`, a separator and the +/// modes. A new matte layer keeps each layer's mode (the matte itself, if among them, is left +/// out); a mode without a matte layer takes the layer above, like Layer ▸ Track Matte (#864: it +/// used to do nothing). +fn track_matte_pick(layers: &[u64], candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { if i == 0 { - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + return Some(("layer.setTrackMatte".into(), json!({"layers": layers, "matte": null}))); } if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + let layers: Vec = layers.iter().copied().filter(|l| *l != m.0).collect(); + return (!layers.is_empty()).then(|| ("layer.setTrackMatte".into(), json!({"layers": layers, "matte": m.0, "kind": "preserve"}))); } let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; - Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) + Some(("layer.trackMatte".into(), json!({"layers": layers, "op": kind_name(k)}))) } fn kind_name(k: MatteKind) -> &'static str { @@ -5448,7 +5539,8 @@ fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) { let Value::Scalar(v) = value else { return }; let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid); - let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = app.tokens.for_value(prop.has_expression()); let (x, y) = (at.x, at.y - 9.0); let (min, max, dec, speed) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)), @@ -5456,7 +5548,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> _ => (-1e9, 1e9, 1, 1.0), }; if matches!(prop.ui, ParamUi::Angle) { - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt); app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name); app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -5472,7 +5564,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, 0)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { @@ -5485,6 +5577,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec { let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid); let t = app.tokens; + let vt = t.for_value(prop.has_expression()); let (mut x, y) = (at.x, at.y - 9.0); let is_3d = layer.is_3d(); let mut dims = vec![]; @@ -5495,15 +5588,17 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). - let linkable = feather || (pct && prop.match_id == "scale"); - let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + // Rectangle and Ellipse Path Size have it too, off by default (#2489). + let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse")); + let linkable = feather || size || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !size); if linkable { let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions")); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); - if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { - app.ui.timeline.unlinked.insert(uid); + if resp.clicked() { + app.ui.timeline.toggle_link(uid, !size); } x += 18.0; } @@ -5515,7 +5610,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, d)), c[d], &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); dims.push(r); @@ -5538,7 +5633,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text); x += 6.0; } } @@ -5613,7 +5708,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, ve.set(actions, json!(ni)); } } else { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text); } } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 0355ea5ed..12259abf7 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -79,7 +79,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); let candidates = [top.id, matte.id]; let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { - let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + let (id, params) = track_matte_pick(&[find(s, name).id.0], &candidates, i, choice).expect("a menu item is an action"); s.execute(&id, params) }; pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); @@ -97,7 +97,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); assert!(find(&s, "Chain Bottom").track_matte.is_none()); - assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + assert!(track_matte_pick(&[bottom.id.0], &candidates, 1, "-").is_none(), "a separator does nothing"); } /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. @@ -535,3 +535,13 @@ fn disk_bar_range_covers_partial_frames() { let above_limit = TMap { start: 100_001.0, ..tm }; assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); } +#[test] +fn layer_drop_target_skips_moving_layers_beyond_the_viewport() { + let mut rows: Vec = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect(); + rows.insert(4, Row { layer: LayerId(4), depth: 1, kind: RowKind::Prop { uid: 0 } }); + let visible: Vec<_> = (2..=4).map(|id| (Rect::NOTHING, LayerId(id))).collect(); + for at in [Some(2), None] { + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5]), Some(LayerId(6)), "skip moving headers and their properties across the viewport edge"); + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5, 6]), None, "the stack ends when no stationary layer follows"); + } +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bcae99bc5..c9386f154 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { phase_frame(app, &mut v); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: v.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; phase_guides(app, &ectx, &mut v); phase_layers(app, ui, &ectx, &mut v); phase_scene(app, &ectx, &mut v); @@ -935,11 +944,14 @@ fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp } }; ctx.set_cursor_icon(cursor); - // Scroll wheel zooms around the pointer. - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.abs() > 0.1 && area.contains(hp) { - let k = (scroll / 300.0).exp(); - let nz = (zoom * k).clamp(0.01, 32.0); + // The mouse wheel and a trackpad pinch zoom around the pointer; a two-finger trackpad + // scroll pans. + let scroll = super::viewer_scroll(&ctx); + if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + scroll.pan.x, app.ui.viewer.pan[1] + scroll.pan.y]; + } + if (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { + let nz = (zoom * scroll.zoom).clamp(0.01, 32.0); let before = map.to_comp(hp); let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz); let new_center = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0); @@ -1352,8 +1364,9 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: } } Gesture::Vertices { start, src, applied, inv, layer } => { - // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). - let want = [cpt[0] - start[0], cpt[1] - start[1]]; + // The grabbed vertex snaps (to other layers' vertices and features, guides, grid); + // Shift keeps it to one axis, as a layer move does (#1964). + let want = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift); let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods); let total = [want[0] + c[0], want[1] + c[1]]; let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1])); @@ -1808,7 +1821,7 @@ use geometry::{ pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; +pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 395219a05..0cd579883 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -4,14 +4,17 @@ use super::{ pos2, }; -/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Visible layers eligible for viewer hit tests, locked or not, in front-to-back order. /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After /// Effects), even when hidden, locked or outside its in-out range. -pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { +fn hit_testable_layers(comp: &Comp, time: Tick) -> impl Iterator { let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); - comp.layers - .iter() - .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) + comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + hit_testable_layers(comp, time).filter(|l| !l.switches.locked) } /// Selectable layers under a comp point, front to back. @@ -24,6 +27,18 @@ pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Laye layers_at(ectx, cpt).next() } +/// Topmost visible layer under a comp point, locked or not. An Alt-drop uses it so a locked layer in +/// front is refused rather than skipped, which would replace the layer behind it. +pub(crate) fn layer_at_including_locked<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + // A layer scaled to nothing has a degenerate quad that `point_in_quad` accepts everywhere. + hit_testable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_area(&q) > 1e-9 && point_in_quad(cpt, &q))) +} + +/// Area of a quad (shoelace formula); zero when its corners coincide or are collinear. +fn quad_area(q: &[[f64; 2]; 4]) -> f64 { + (0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::().abs() / 2.0 +} + /// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in /// After Effects, a selected layer under the point comes before the layers in front of it, so it /// can be dragged where they cover it; otherwise the topmost layer (#230). @@ -202,6 +217,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: time: app.session.time(), expr: None, footage: None, + property_overrides: &[], + media_overrides: &[], }; let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs index 30b3f4d00..59e3e513d 100644 --- a/crates/ui-egui/src/panels/viewer/view.rs +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -44,7 +44,16 @@ pub(super) fn aux_views( // layout shows them), evaluated on a snapshot of the project. let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp, + time: t, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selected = app.session.state.selected_layers.clone(); let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 44364a49d..ac56d9acb 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,7 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped //! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the -//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! pointer, outlined while it is dragged (#88). Alt/Option-dropping a Project item replaces the source of +//! the layer under the pointer instead, as in the Timeline (#2173). Agents do the same with `layer.addItem {position}`, //! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; @@ -10,7 +11,7 @@ use egui::{Rect, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; use super::DragPayload; -use super::viewer::{ViewerMap, layer_at, layer_quad}; +use super::viewer::{ViewerMap, layer_at, layer_at_including_locked, layer_quad}; use crate::EffectcraftApp; /// While something is dragged over the viewer, show where it goes; drop it on release. @@ -23,6 +24,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); + // Alt/Option-drop of a Project item replaces the source of the layer under the pointer (After + // Effects), as in the Timeline; it never adds a layer. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), DragPayload::Item(_)); let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at) .map(|l| { @@ -42,6 +46,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Item(item) if alt_item => alt_replace(app, ectx, painter, map, stroke, at, *item).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -55,7 +60,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Property { .. } => vec![], }; // Layer drops outline the target layer; the others outline the whole viewer. - let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + let layer_drop = alt_item || matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } @@ -80,3 +85,21 @@ fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> { let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 }; (w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32]) } + +/// The `layer.replaceSource` for an Alt/Option-drop of Project item `item` at comp point `at`: the +/// topmost layer there, outlined, if it can take the item as its source (a locked one cannot). +fn alt_replace( + app: &EffectcraftApp, + ectx: &EvalCtx, + painter: &egui::Painter, + map: &ViewerMap, + stroke: Stroke, + at: [f64; 2], + item: u64, +) -> Option<(&'static str, Value)> { + let layer = layer_at_including_locked(ectx, at).filter(|l| super::timeline::replace_source_target(app, ectx.comp_id, ItemId(item), l.id, ectx.comp))?; + if let Some((_, q, _)) = layer_quad(ectx, layer) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + Some(("layer.replaceSource", json!({"layers": [layer.id.0], "item": item, "fixExpressions": false}))) +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3a96d3c50..c1320054a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -307,6 +307,21 @@ impl Default for ViewerState { } impl TimelineState { + /// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size) + /// records it in `linked`. + pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool { + if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) } + } + + /// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]). + pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) { + let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked }; + if !set.remove(&uid) { + set.insert(uid); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -414,6 +429,10 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, + /// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was + /// turned on (#2489). + #[serde(default)] + pub linked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -466,6 +485,7 @@ impl Default for TimelineState { expr_closed: BTreeSet::new(), expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), + linked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, scroll_to: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b1538971f..aa1b30350 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,6 +6,10 @@ use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use egui::FontTweak; +#[cfg(not(target_arch = "wasm32"))] +use egui::emath::GuiRounding as _; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; use serde::{Deserialize, Serialize}; @@ -125,6 +129,16 @@ impl Tokens { pub fn grad_top(&self, c: Color32) -> Color32 { if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } } + + /// The tokens a property's value fields use (#1306): with an expression driving the property + /// (`expression`, [`Property::has_expression`]) its values show in the expression colour, red + /// as in After Effects, hovered and dragged too; otherwise these tokens. For the values only: + /// the name, stopwatch and swatches keep the ordinary tokens. + /// + /// [`Property::has_expression`]: effectcraft_engine::project::Property::has_expression + pub fn for_value(self, expression: bool) -> Tokens { + if expression { Tokens { hot_text: self.expr_text, accent_hover: self.expr_text, ..self } } else { self } + } } impl Tokens { @@ -383,6 +397,47 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ ('ა', "georgian-system"), ]; +/// The size the fallback baseline correction is derived at. The correction is an em fraction, so the +/// value holds at every interface size; any size in the interface's range gives the same answer. +#[cfg(not(target_arch = "wasm32"))] +const BASELINE_METRICS_PX: f32 = 12.0; + +/// A face's ascent and row height at `px`, rounded the way epaint rounds them: ascent, descent and +/// line gap separately to 1/32, then `ascent + descent + line_gap` (epaint's descent is negative, +/// this one is positive). +#[cfg(not(target_arch = "wasm32"))] +fn ascent_and_height(m: &effectcraft_text::fonts::VMetrics) -> (f32, f32) { + let ascent = m.ascent.round_ui(); + let descent = m.descent.round_ui(); + let line_gap = m.line_gap.round_ui(); + (ascent, ascent + descent + line_gap) +} + +/// The `FontTweak` that puts a fallback face's glyphs on the row font's baseline. +/// +/// epaint draws a glyph at `A_face + 0.5·(H_row − H_face)` (`galley_from_rows` in epaint's +/// `text_layout.rs`), where `A` and `H` are the ascent and the row height of the face that draws the +/// glyph and of the row's own font, both from `styled_metrics` in epaint's `font.rs`. Shifting the +/// face by `(A_row − A_face) − 0.5·(H_row − H_face)` cancels the offset; as an em fraction one +/// number serves every size, because `FontTweak` applies `size · y_offset_factor`. +/// +/// The same expression also carries `valign · (max_row_height − line_height)`, which cancels here: +/// every glyph of a row takes the section font's row height, so it is the same term for the Latin +/// and the fallback glyph. The remaining `snap_to_pixel` difference is likewise shared and cancels. +/// (#1309) +/// +/// The UI families do not share a row font — Proportional uses Inter, Monospace JetBrains Mono — +/// and their factors differ by 0.0026 em, which is 0.03 px at 12 pt: inside the 1/32 that is already +/// rounded away above, so one factor per face is enough. The test +/// `fallback_glyphs_sit_on_the_row_baseline` pins the result. +#[cfg(not(target_arch = "wasm32"))] +fn baseline_tweak(row: &effectcraft_text::fonts::Face, face: &effectcraft_text::fonts::Face) -> FontTweak { + let px = BASELINE_METRICS_PX; + let (a_row, h_row) = ascent_and_height(&row.metrics(px)); + let (a_face, h_face) = ascent_and_height(&face.metrics(px)); + FontTweak { y_offset_factor: ((a_row - a_face) - 0.5 * (h_row - h_face)) / px, ..Default::default() } +} + /// Register the system fallbacks for scripts the bundled fonts lack in every font family, and /// return their names in registration order (empty when nothing installed covers a probe, so a /// machine without those fonts simply keeps the bundled ones). A face that covers several probes @@ -415,6 +470,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; + let base_face = fonts::face(base); let han = (han_probe(language), "chinese-system"); let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); @@ -458,6 +514,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() }; + data.tweak = baseline_tweak(&base_face, &face); family_fonts.font_data.insert(name.clone(), Arc::new(data)); for family in family_fonts.families.values_mut() { family.push(name.to_string()); @@ -739,6 +796,64 @@ mod script_font_tests { assert_ne!((meem.uv_rect.min, meem.uv_rect.max), (isolated.min, isolated.max), "the meem takes its initial form"); }); } + + /// A fallback face's glyphs sit on the row font's baseline, in every UI family and at every + /// interface size. (#1309) + /// + /// `A` stands in for the row's baseline: epaint exposes none, but a capital without a descender + /// has its ink bottom on the baseline, so its ink bottom *is* the row's baseline. A glyph with a + /// descender (`g`) or a round one with an overshoot (`O`) would quietly stop holding, so keep `A` + /// as the Latin half of this pair. + /// + /// Both glyphs of `Aあ` end on the baseline, so their ink bottoms agree up to the difference + /// between the two outlines themselves - the kana reaches a little below the Latin letter in the + /// Japanese faces. Without the correction the kana sits 3 pt high at 12 pt (measured), so the + /// tolerance below separates "the correction is applied" from "it is missing"; it is not a claim + /// about the outlines. Two pixels per point, because epaint truncates a glyph's box height to + /// whole pixels, which would otherwise put up to a point of noise on the measurement. + #[test] + fn fallback_glyphs_sit_on_the_row_baseline() { + if !installed('\u{3042}') { + eprintln!("no Japanese system font installed; skipping the baseline check"); + return; + } + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "ja"); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + ctx.fonts_mut(|fonts| { + for family in [ + egui::FontFamily::Proportional, + egui::FontFamily::Monospace, + egui::FontFamily::Name("medium".into()), + egui::FontFamily::Name("semibold".into()), + ] { + for size in [12.0_f32, 15.0, 18.0] { + let font = egui::FontId::new(size, family.clone()); + let galley = fonts.layout_no_wrap("A\u{3042}".into(), font, egui::Color32::WHITE); + let row = &galley.rows[0].glyphs; + assert_eq!(row.len(), 2, "A\u{3042} should shape to two glyphs"); + let (latin, kana) = (bottom(&row[0]), bottom(&row[1])); + let drift = kana - latin; + // Without the correction the kana sits 3-5 pt *above* the Latin ink, so the sign is + // what tells the two apart. With it, the kana reaches a little *below* the Latin + // one, by however far the face's kana overshoot past the baseline - measured at + // 1.94 pt for Noto Sans CJK JP against Yu Gothic's 0.4 - plus up to half a point of + // whole-point rounding in `pos.y` for each glyph. + assert!( + drift > -0.5, + "the kana sits {drift:.2} pt above the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is missing or wrong" + ); + assert!( + drift < 2.5, + "the kana sits {drift:.2} pt below the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is too large" + ); + } + } + }); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 4807e4e40..de2e99ce7 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -197,9 +197,20 @@ impl WorkspaceStore { let path = config.dir().map_or_else(|| FILE.to_string(), |dir| dir.join(FILE).display().to_string()); return Err(format!("{path} couldn't be loaded at startup, so it is left untouched; fix or move it and restart")); } + ensure_positions_ready(ui)?; self.save(Some(config), ui).map(|_| ()) } } +/// Explicit saves must wait until migrated native-window positions refer to their content. +/// Check before capturing a named layout too, so a later background save cannot persist +/// a snapshot made with the old outer-window positions. +pub(crate) fn ensure_positions_ready(ui: &UiState) -> Result<(), String> { + if !ui.native_outer.is_empty() { + return Err("workspace window positions are still being restored; try saving again in a moment".into()); + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs index 93d71fd75..bcb5a9691 100644 --- a/crates/ui-egui/src/workspace_state/legacy.rs +++ b/crates/ui-egui/src/workspace_state/legacy.rs @@ -25,6 +25,9 @@ use super::{Snapshot, VERSION}; use crate::dock::{Layout, PanelKind, Workspace}; use crate::state::UiState; +#[cfg(test)] +mod tests; + #[derive(Deserialize)] enum OldSplitSize { Ratio(f32), @@ -166,7 +169,7 @@ fn node(old: OldNode, depth: usize) -> Result, String> { first: Box::new(node(*a, depth.saturating_add(1))?), second: Box::new(node(*b, depth.saturating_add(1))?), }, - OldNode::Stack { entries } => column(entries)?, + OldNode::Stack { entries } => column(entries, depth)?, }) } @@ -183,7 +186,7 @@ struct Frame { } /// A column of stacked frames as tab groups split top to bottom (see the module docs). -fn column(entries: Vec) -> Result, String> { +fn column(entries: Vec, depth: usize) -> Result, String> { let mut frames: Vec = Vec::new(); // Tabs of closed frames above the first open one. let mut waiting: Vec = Vec::new(); @@ -199,6 +202,9 @@ fn column(entries: Vec) -> Result, String> { } let active = entry.active.min(panels.len().saturating_sub(1)); if entry.open { + if depth.saturating_add(frames.len()) > craft_ui::docking::MAX_DEPTH { + return Err("workspace dock tree exceeds supported limits".into()); + } let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME)); frames.push(Frame { panels, active, height }); } else if let Some(above) = frames.last_mut() { diff --git a/crates/ui-egui/src/workspace_state/legacy/tests.rs b/crates/ui-egui/src/workspace_state/legacy/tests.rs new file mode 100644 index 000000000..a3d182f4f --- /dev/null +++ b/crates/ui-egui/src/workspace_state/legacy/tests.rs @@ -0,0 +1,35 @@ +use super::*; + +fn stack(count: usize) -> OldNode { + OldNode::Stack { + entries: (0..count).map(|_| OldStackEntry { panel: Some(PanelKind::Info), panels: Vec::new(), active: 0, open: true, height: None }).collect(), + } +} + +#[test] +fn stacked_frames_respect_the_remaining_tree_depth() { + let limit = craft_ui::docking::MAX_DEPTH; + assert!(node(stack(limit + 1), 0).is_ok()); + assert!(node(stack(limit + 2), 0).is_err()); + assert!(node(stack(1), limit).is_ok()); + assert!(node(stack(2), limit).is_err()); + assert!(node(stack(limit), 2).is_err()); +} + +#[test] +fn oversized_legacy_stack_load_does_not_abort() { + const CHILD: &str = "EFFECTCRAFT_TEST_LEGACY_STACK_CHILD"; + if std::env::var_os(CHILD).is_some() { + let entry = serde_json::json!({"panel": "Info", "open": true}); + let value = serde_json::json!({"workspaces": {"Oversized": {"Stack": {"entries": vec![entry; 20_000]}}}}); + assert!(serde_json::to_vec(&value).unwrap().len() < super::super::MAX_BYTES); + assert!(migrate(value, &UiState::default()).is_err()); + return; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "workspace_state::legacy::tests::oversized_legacy_stack_load_does_not_abort", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} diff --git a/crates/ui-egui/src/workspace_state/tests.rs b/crates/ui-egui/src/workspace_state/tests.rs index e7b6d9aae..aab2814ba 100644 --- a/crates/ui-egui/src/workspace_state/tests.rs +++ b/crates/ui-egui/src/workspace_state/tests.rs @@ -65,6 +65,29 @@ fn corrupt_future_or_unknown_workspace_keeps_defaults_and_original_file() { /// saved as "My Layout"; then Layer closed. const V1: &str = include_str!("../../tests/fixtures/workspaces-v1.json"); +#[test] +fn explicit_save_reports_pending_migration_and_can_be_retried() { + let config = MemoryConfig::default(); + config.write(FILE, V1).unwrap(); + let mut ui = UiState::default(); + let mut store = WorkspaceStore::load(Some(&config), &mut ui); + assert!(!ui.native_outer.is_empty()); + assert!(!store.save(Some(&config), &ui).unwrap(), "background saves defer quietly"); + let error = store.save_now(Some(&config), &ui).unwrap_err(); + assert!(error.contains("positions") && error.contains("try saving again"), "{error}"); + assert_eq!(config.read(FILE).as_deref(), Some(V1)); + assert_eq!(config.read(BACKUP), None); + assert!(store.has_changes(&ui)); + + crate::dock_ui::native_outer_to_content(&mut ui, None, None, 3.0); + store.save_now(Some(&config), &ui).unwrap(); + assert_eq!(config.read(BACKUP).as_deref(), Some(V1)); + let mut restored = UiState::default(); + let loaded = WorkspaceStore::load(Some(&config), &mut restored); + assert_eq!(loaded.error, None); + assert_eq!(Snapshot::capture(&restored), Snapshot::capture(&ui)); +} + #[test] fn version_one_file_loads_with_every_panel_in_place() { let config = MemoryConfig::default(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..54f641687 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -8,6 +8,7 @@ mod ui_agent_audit; mod ui_angles; mod ui_audio_scrub; mod ui_azerty_shortcuts; +mod ui_batch_links; mod ui_batch_switches; mod ui_blank_context_menus; mod ui_camera_tracker; @@ -20,6 +21,7 @@ mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_expression_values; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; @@ -39,6 +41,7 @@ mod ui_layer_source_picker; mod ui_light_settings; mod ui_live_preview; mod ui_locked_color; +mod ui_marker_names; mod ui_media_browser_cache; mod ui_media_browser_copy_path; mod ui_menus; @@ -79,6 +82,7 @@ mod ui_tracker; mod ui_unreadable_footage; mod ui_video_preview_off; mod ui_viewer; +mod ui_viewer_trackpad; mod ui_viewers; mod ui_visual_review; mod ui_web_gpu_storage; diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index c71fadf3c..48e8b8283 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -278,6 +278,31 @@ fn ui_dock_and_window_panel_commands_parse_craft_ui_commands() { assert_eq!(app.ui.dock, Workspace::new(dock::workspace("Default"))); } +#[test] +fn raw_open_actions_are_rejected_through_every_docking_route() { + let mut app = EffectcraftApp::new(Session::default()); + let before = app.ui.dock.clone(); + for panel in [PanelKind::ScriptPanel(77), PanelKind::Viewer(42)] { + for action in [ + Action::Open { panel, anchor: Some(PanelKind::Project) }, + Action::OpenAt { panel, anchor: PanelKind::Project, placement: Placement::Tab { before: None } }, + ] { + for id in ["ui.dock", "window.panel.layout", "window.dockAction"] { + let error = invoke(&mut app, id, json!({"action": action})).unwrap_err(); + assert!(error.contains("panel open commands"), "{id}: {error}"); + assert_eq!(app.ui.dock, before, "{id} must leave the layout unchanged"); + assert!(app.session.script_ui.window(77).is_none()); + assert!(!app.ui.viewers.contains_key(&42)); + } + } + } + // The normal operation still opens a panel and the raw form still activates one. + invoke(&mut app, "ui.dock", json!({"operation": "open", "panel": "Align"})).unwrap(); + assert!(app.ui.dock.is_open(PanelKind::Align)); + invoke(&mut app, "window.panel.layout", json!({"action": {"Activate": {"panel": "Project"}}})).unwrap(); + assert!(app.ui.dock.is_visible(PanelKind::Project)); +} + #[test] fn catalog_close_keeps_dynamic_panel_identity_and_releases_viewers() { let mut app = EffectcraftApp::new(Session::default()); diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index c748afe8e..a1f0a887b 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -166,96 +166,90 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() { assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped"); } -/// A preview with audio waits for frames like a silent one (#103): while the frames ahead aren't -/// cached, every frame shows as it renders, silently; the sound starts once the rest of the -/// preview is cached, and a frame that isn't cached stops it instead of being skipped. +/// The comp's frame at the current time. +fn frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> i64 { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) +} + +/// The frame under the preview audio's first sample (its clock before the device pulls any). +fn audio_frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> Option { + let app = h.state(); + let a = app.audio.as_ref()?; + Some(effectcraft_ui_egui::audio::frame_of_sample(a.clock(), a.rate, app.session.project.comp(cid).unwrap().frame_rate)) +} + +/// Step until the preview's sound starts (or 60 s pass, for a loaded machine), checking the +/// playhead holds `first` meanwhile. Returns whether the sound started. +fn until_sound(h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, first: i64) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + step(h); + if h.state().audio.is_some() { + return true; + } + assert_eq!(frame(h, cid), first, "the playhead holds the first frame until the sound can start"); + assert!(h.state().playback.starting && h.state().playback.playing); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + false +} + +/// One step with the app clock, which playback runs on, a step (1/30 s) further on: these tests +/// count time in steps, however long a step takes on a loaded machine. +fn step(h: &mut Harness<'_, EffectcraftApp>) { + h.state_mut().clock_mut().advance(1.0 / 30.0); + h.step(); +} + +/// Starting a preview with sound before its frames are cached (#947): the playhead holds the +/// first frame while the frames ahead render, then sound and picture start together from it (the +/// sound used to start at a later frame, losing the opening). A frame that isn't cached later on +/// still stops the sound rather than being skipped (#103). #[test] -fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { +fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { let fake = Fake::default(); let (mut app, cid) = setup(true, &fake); - // Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the - // silently shown frames and the moment the sound starts do not depend on the host's threads. + // Release only the frames this test chooses, and drive the app clock: rendering cannot race + // the clock on a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); - let ahead = (app.frames.parallelism() * 2).max(4); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); - // Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock - // explicitly at one frame per step. h.state_mut().use_manual_clock(); h.run_steps(2); gate.discard(&mut h); - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) - }; let now = h.state().now(); h.state_mut().play(now); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Show frames while only those frames are cached: they play silently first. - for _ in 0..4 { - let target = last + 1; - gate.render(&mut h, cid, &[target]); - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - // Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never - // jump over a frame. - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; - } - // A frame that has not rendered holds the clock at the frame on screen; once it renders it is - // shown, never jumped over. This exercises the production hold path, not a test-only shortcut. - let waiting = last; - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped"); - gate.render(&mut h, cid, &[waiting + 1]); - // f64 clock accumulation need not put the next step exactly on the next frame boundary; - // keep stepping until the held frame appears, and never allow it to jump past that frame. - let mut showed_waiting = false; - for _ in 0..4 { - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped"); - if f == waiting + 1 { - showed_waiting = true; - break; - } - } - assert!(showed_waiting, "the waiting frame is shown once it renders"); - shown += 1; - last = waiting + 1; - assert!(shown > 0, "frames played silently first"); - assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits"); - // Cache everything ahead; only then may the sound start. - let end = h.state().playback.plan.expect("a preview plan").end; - let rest: Vec = ((last + 1)..=end).collect(); - gate.render(&mut h, cid, &rest); - h.step(); + assert!(h.state().playback.starting && h.state().audio.is_none(), "nothing is cached: no sound yet"); + // Half the frames ahead cached: the playhead holds the first frame, silently. + let half: Vec = (0..ahead / 2).collect(); + gate.render(&mut h, cid, &half); + step(&mut h); + assert!(h.state().playback.starting && h.state().audio.is_none(), "{} of {ahead} frames ahead cached: no sound yet", half.len()); + assert_eq!(frame(&h, cid), 0, "the playhead holds the first frame"); + // All of them: the sound starts, from that frame. + let all: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &all); + step(&mut h); assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame, not a later one"); + assert!(!h.state().playback.starting); // A frame that isn't cached stops the sound rather than being skipped. gate.discard(&mut h); - h.state().frames.set_budget(0); h.state().frames.clear(); h.step(); assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } -/// A preview whose frames render in real time sounds without waiting for the whole work area to -/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead -/// cached, the sound starts, still without skipping a frame before it. +/// A long (30 s) preview sounds once the frames ahead of its first frame are cached, not the +/// whole work area (#208), and from that first frame (#947). #[test] -fn preview_with_audio_sounds_once_rendering_keeps_up() { +fn preview_with_audio_sounds_without_caching_the_work_area() { let fake = Fake::default(); let (mut app, cid) = setup_long(true, &fake, 30.0); - // Release only the frames this test chooses, and drive the app clock: rendering cannot race - // the clock, so a frame is never skipped by a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); @@ -264,36 +258,424 @@ fn preview_with_audio_sounds_once_rendering_keeps_up() { h.run_steps(2); gate.discard(&mut h); let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + let now = h.state().now(); + h.state_mut().play(now); + let frames: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &frames); + step(&mut h); + assert!(h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let cached = h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); + assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area"); +} + +/// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. +fn pull(feed: &AudioFeed, frames: usize) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while feed.queued_frames() < frames && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let mut out = vec![0.0f32; frames * 2]; + feed.pull(&mut out); + out +} + +/// The opening of the preview is heard (#947): a sound that lasts only the first 100 ms after the +/// start, from the beginning of the comp or from the middle, reaches the output device. +#[test] +fn a_sound_in_the_first_100_ms_is_heard() { + for start in [0.0, 1.0] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(false, &fake, 4.0); + // A tone only from `start` to `start` + 0.1 s. + let l = app.session.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + app.session.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + app.session.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + app.session.execute("layer.timing", json!({"layers": [l], "in": start, "out": start + 0.1})).unwrap(); + app.session.execute("time.set", json!({"time": start})).unwrap(); + let first = (start * 30.0) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(until_sound(&mut h, cid, first), "{start}: the sound started"); + assert_eq!(audio_frame(&h, cid), Some(first), "{start}: from the first frame"); + let feed = fake.0.lock().unwrap().0.clone().expect("the output opened"); + // The first 100 ms at 48 kHz. + let out = pull(&feed, 4800); + assert!(out.iter().any(|v| v.abs() > 0.01), "{start}: the opening tone reached the output"); + } +} + +/// A preview without video starts its sound at once, from the first frame (unchanged), and so +/// does one whose frames are already cached. +#[test] +fn audio_only_previews_start_at_once() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play_with(now, effectcraft_engine::preview::PreviewShortcut::AltNumpad0); + assert!(h.state().audio.is_some() && !h.state().playback.starting, "Video off: the sound starts at once"); + assert_eq!(audio_frame(&h, cid), Some(0)); +} + +/// Without an output device the preview still plays, from the first frame, once the frames ahead +/// are cached; Stop while it prepares leaves nothing playing. +#[test] +fn no_device_plays_the_picture_and_stop_ends_the_wait() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 10.0); + app.hooks.audio_device = Some(Box::new(|_| None)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(h.state().playback.starting); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting && h.state().audio.is_none() && h.state().playback.playing, "no device: the picture plays alone"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "from the first frame on"); + // Stop while preparing. + h.state_mut().stop(); + let now = h.state().now(); + let device = fake.clone(); + h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(device.clone()) as Box))); + h.state().frames.clear(); + h.state_mut().play(now); + // One step into preparing: the frames ahead are requested and it waits for them. + step(&mut h); + assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing"); + h.state_mut().stop(); + // The renders it queued finish after Stop: they must not start the sound. + for _ in 0..30 { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays"); + assert!(fake.0.lock().unwrap().0.is_none(), "the output never opened"); +} + +/// Start a preview and step until it leaves its startup (or 60 s pass). Returns whether it did. +fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { + let now = h.state().now(); + h.state_mut().play(now); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + !h.state().playback.starting +} + +/// RAM preview budgets just below, at and above the prefetch's frames ahead, and small ones, from +/// a cold cache, with and without an output device: the start never waits for a frame the +/// prefetch doesn't request (with a budget of 4 frames it asks for 3 ahead of the playhead). +#[test] +fn startup_finishes_at_any_ram_preview_budget() { + let probe = Fake::default(); + let (app, _) = setup(true, &probe); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. + let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; + fits.sort(); + fits.dedup(); + for fit in fits { + for device in [true, false] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + if !device { + app.hooks.audio_device = Some(Box::new(|_| None)); + } + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let budget = ((fit as f64 + 0.5) * 9216.0 / 0.9) as usize; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + assert!(until_started(&mut h), "fit {fit} (ahead {ahead}), device {device}: the preview left its startup"); + assert_eq!(h.state().audio.is_some(), device, "fit {fit}, device {device}"); + if device { + assert_eq!(audio_frame(&h, cid), Some(0), "fit {fit}: from the first frame"); + } + } + } +} + +/// An audible 160×120 comp with a 3D layer and Draft 3D, Extended Viewer on: its viewer frames +/// render the pasteboard too, larger than the comp. Returns the harness once the first frame has +/// rendered, the comp, and that frame's size. +fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [usize; 2]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#4060a0"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap(); + s.prefs.three_d.extended_viewer = true; + let mut app = EffectcraftApp::new(s); + let f = fake.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + // The viewer shows the pasteboard: frames render the extended region. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let size = loop { + step(&mut h); + let series = h.state().shown_series(cid); + if let Some(img) = h.state().frames.get(&series) { + break img.size(); + } + assert!(std::time::Instant::now() < deadline, "the first frame rendered"); + std::thread::sleep(std::time::Duration::from_millis(5)); }; + assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on"); + (h, cid, size) +} + +/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized +/// frames holds fewer of them, and the start waits only for as many as really fit (#947). +#[test] +fn startup_finishes_when_extended_viewer_frames_are_larger() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let ahead = (h.state().frames.parallelism() * 2).max(4); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. + let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; + assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + // The first frame renders before the harness clock moves on: each step is 1/30 s however long + // it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that + // clock, and this test is about the wait for the frames ahead. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert!(h.state().audio.is_some(), "with its sound"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; +/// here it can't be cached until the RAM preview grows): the start keeps holding it, then the +/// sound starts from it and keeps playing. It used to give up two seconds after Play, start the +/// sound on the uncached frame and stop it at once, so the opening played silently. +#[test] +fn a_slow_first_frame_keeps_its_opening_sound() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + h.state().frames.set_budget((comp_bytes + real_bytes) / 2); + h.state().frames.clear(); let start = h.state().now(); h.state_mut().play(start); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Two seconds of playback at one frame per clock step: keep the prefetch window cached, but - // not the rest of the 30 s comp, so the sound has to start by the real-time rule. - for step in 1..=60 { - let target = last + 1; - let window: Vec = (target..target + ahead).collect(); - gate.render(&mut h, cid, &window); - // Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated - // f64 error can put one step just below a frame boundary and the next just above it, so - // `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step. - let now = h.state().now(); - let tick_time = start + step as f64 / 30.0; - h.state_mut().clock_mut().advance(tick_time - now); - h.step(); - if h.state().audio.is_some() { - break; - } - let f = frame(&h); - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; + while h.state().now() - start < 3.0 { + step(&mut h); + assert!(h.state().playback.starting, "it still holds the first frame"); + assert_eq!(frame(&h, cid), 0); } - assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); - assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound yet"); + h.state().frames.set_budget(1 << 30); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert_eq!(audio_frame(&h, cid), Some(0), "with its sound from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// An output device whose start fails. +#[derive(Clone, Default)] +struct Broken; + +impl AudioDevice for Broken { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _feed: Arc) -> Result<(), String> { + Err("device unavailable".into()) + } + fn stop(&mut self) {} +} + +/// A device that fails to start: the picture plays on its own from the first frame. +#[test] +fn a_device_that_fails_to_start_plays_the_picture() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + app.hooks.audio_device = Some(Box::new(|_| Some(Box::new(Broken) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h), "it left its startup"); + assert!(h.state().audio.is_none() && h.state().playback.playing); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "the picture plays"); +} + +/// An output that plays: a thread takes the feed's samples in real time, as a sound card would. +#[derive(Clone, Default)] +struct Playing(Arc>); + +impl AudioDevice for Playing { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, feed: Arc) -> Result<(), String> { + let stop = self.0.clone(); + std::thread::spawn(move || { + let mut buf = vec![0.0f32; 480 * 2]; + while !*stop.lock().unwrap() { + feed.pull(&mut buf); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + Ok(()) + } + fn stop(&mut self) { + *self.0.lock().unwrap() = true; + } +} + +/// Once started, the sound's clock moves the picture on from the first frame, and a frame that +/// isn't cached still stops the sound rather than being skipped. +#[test] +fn a_started_preview_follows_its_sound() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + let device = Playing::default(); + let d = device.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(d.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h) && h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) < 5 && std::time::Instant::now() < deadline && h.state().audio.is_some() { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(frame(&h, cid) >= 5, "the picture follows the sound's clock (at frame {})", frame(&h, cid)); + h.state().frames.set_budget(0); + h.state().frames.clear(); + step(&mut h); + assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound"); + *device.0.lock().unwrap() = true; +} + +/// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait +/// for frames that can never be cached. The sound isn't started on a frame that can't be shown +/// (a stall would stop it at once): the preview goes on as a stalled one does. +#[test] +fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { + for budget in [0, 9216 / 2] { + let fake = Fake::default(); + let (app, _) = setup_long(true, &fake, 8.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + assert!(!h.state().playback.starting, "budget {budget}: no wait"); + assert!(fake.0.lock().unwrap().0.is_none(), "budget {budget}: no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "budget {budget}: it goes on as a stalled preview"); + } +} + +/// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame +/// larger than the whole budget): the start gives up after STARTUP_LIMIT instead of holding the +/// first frame for ever, without starting the sound on a frame it can't show. +#[test] +fn startup_gives_up_on_a_first_frame_that_never_caches() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + // Room for a comp-sized frame but not a real one: the current frame can never be cached. + let budget = (comp_bytes + real_bytes) / 2; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let start = h.state().now(); + h.state_mut().play(start); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + assert_eq!(frame(&h, cid), 0, "it holds the first frame while it waits"); + step(&mut h); + } + let waited = h.state().now() - start; + assert!(!h.state().playback.starting, "it gave up waiting"); + assert!((9.9..12.0).contains(&waited), "after about ten seconds ({waited:.2} s)"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "it goes on as a stalled preview"); +} + +/// Adding markers while a preview sounds (Num*, one per beat) keeps the sound going (#1083): a +/// marker changes no pixels, so no cached frame is dropped and no frame has to be rendered again +/// (which would stop the sound until the frames ahead are cached). +#[test] +fn adding_markers_does_not_stop_the_sound() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + // Everything is cached before the preview starts, so the sound starts at once. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app); + h.run_steps(2); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + while h.state().audio.is_none() && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(h.state().audio.is_some(), "the sound started"); + for beat in 0..6 { + let t = 0.1 + 0.05 * beat as f64; + h.state_mut().session.execute("comp.addMarker", json!({"comp": cid.0, "time": t, "comment": "beat"})).unwrap(); + h.step(); + let app = h.state(); + assert!(app.playback.playing && app.audio.is_some() && !app.playback.audio_held, "marker {beat}: still playing with sound"); + } + assert_eq!(h.state().session.project.comp(cid).unwrap().markers.len(), 6); } diff --git a/crates/ui-egui/tests/it/ui_batch_links.rs b/crates/ui-egui/tests/it/ui_batch_links.rs new file mode 100644 index 000000000..5b2cea090 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_batch_links.rs @@ -0,0 +1,209 @@ +//! Parent and Track Matte for several layers at once (#1260): on a selected layer's row, the +//! Timeline's Parent and Track Matte menus and pick whips set every selected layer; on an +//! unselected row, that layer alone. One undo step each; each layer keeps its own matte mode. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, MatteKind}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use serde_json::json; + +/// Solids A, B, C, P (moved and scaled, a parent) and the mattes X and Y; stacked Y, X, P, C, B, A +/// from the top. The Track Matte column is shown. +fn harness() -> (Harness<'static, EffectcraftApp>, [u64; 6]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Links", "width": 320, "height": 180, "duration": 4})).unwrap(); + let ids = ["A", "B", "C", "P", "X", "Y"] + .map(|n| s.execute("layer.newSolid", json!({"name": n, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap()); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/position", "value": [60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/scale", "value": [50, 50]})).unwrap(); + s.history.undo.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.show_modes = true; + h.state_mut().ui.timeline.columns_w = 900.0; + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.step(); + press(h, at, true); + h.step(); + press(h, at, false); + h.run_steps(3); +} + +/// Open the menu at automation id `dropdown` and choose `item` in it. +fn choose(h: &mut Harness<'_, EffectcraftApp>, dropdown: &str, item: &str) { + let menu = rect(h, dropdown); + // Other rows' menus show their choice as well ("No Track Matte"): the entry is a node the + // opened menu added. + let shown: Vec = h.query_all_by_label(item).map(|n| n.rect()).collect(); + click(h, menu.center()); + // (The menu may open above it to fit the window: the entry nearest to it.) + let off = |r: &Rect| (r.center().y - menu.center().y).abs(); + let at = h.query_all_by_label(item).map(|n| n.rect()).filter(|r| !shown.contains(r)).min_by(|a, b| off(a).total_cmp(&off(b))); + let at = at.unwrap_or_else(|| panic!("no {item} in the {dropdown} menu")).center(); + click(h, at); +} + +/// Drag the pick whip `whip` onto layer `onto`'s row. +fn whip(h: &mut Harness<'_, EffectcraftApp>, whip: &str, onto: u64) { + let (from, to) = (rect(h, whip).center(), rect(h, &format!("timeline.layer.{onto}.row")).center()); + h.event(Event::PointerMoved(from)); + h.step(); + press(h, from, true); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + press(h, to, false); + h.run_steps(3); +} + +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +fn parent(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().parent.map(|p| p.0) +} + +fn position(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + +fn matte(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option<(u64, MatteKind)> { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) +} + +/// The project and its undo steps as they are now, to compare with after an undo or a refusal. +fn mark(h: &Harness<'_, EffectcraftApp>) -> (std::sync::Arc, usize) { + (h.state().session.project.clone(), h.state().session.history.undo.len()) +} + +fn unchanged(h: &Harness<'_, EffectcraftApp>, m: &(std::sync::Arc, usize)) -> bool { + h.state().session.project.same_content(&m.0) && h.state().session.history.undo.len() == m.1 +} + +#[test] +fn parent_menu_and_pick_whip_parent_the_selected_layers() { + let (mut h, [a, b, c, p, ..]) = harness(); + let start = position(&h, a); + select(&mut h, &[a, b]); + let before = mark(&h); + // The Parent menu on A's (selected) row parents A and B, kept where they are. + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [Some(p), Some(p), None]); + assert!(position(&h, a) != start && position(&h, a) == position(&h, b), "both re-expressed in P's space"); + assert_eq!(h.state().session.history.undo.len(), before.1 + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both back"); + // The pick whip of an unselected row parents that layer alone. + whip(&mut h, &format!("timeline.layer.{c}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [None, None, Some(p)]); + // A selected row's whip parents the selection, less the parent itself if selected. + select(&mut h, &[a, b, p]); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, p)], [Some(p), Some(p), None]); + // A cycle (P under A, while A is under P) is refused as a whole. + select(&mut h, &[p, c]); + let before = mark(&h); + choose(&mut h, &format!("timeline.layer.{p}.parent"), "6. A"); + assert!(unchanged(&h, &before), "the refused parenting changed nothing"); + assert_eq!([parent(&h, p), parent(&h, c)], [None, Some(p)]); +} + +#[test] +fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() { + let (mut h, [a, b, c, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": b, "matte": x, "kind": "alpha"})).unwrap(); + select(&mut h, &[a, b]); + let before = mark(&h); + // A new matte layer for both; each keeps its mode; C (not selected) keeps none. + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), None]); + assert!(!h.state().session.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "Y is hidden as a matte"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both mattes and Y's visibility back"); + // A mode for both, then No Track Matte for both. + choose(&mut h, &format!("timeline.layer.{b}.trackMatte"), "Luma Inverted Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [Some((x, MatteKind::LumaInverted)), Some((x, MatteKind::LumaInverted))]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "No Track Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [None, None]); + // The track matte pick whip: a selected row's sets the selection's, an unselected row's + // its own. + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha)), None]); + whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x); + assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]); +} + +fn video(h: &Harness<'_, EffectcraftApp>, id: u64) -> bool { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.video +} + +/// A locked layer in the selection stays selected, and keeps its parent, its transform and its +/// matte while another selected row's menus and pick whips change the rest. +#[test] +fn locked_selected_layers_keep_their_parent_and_matte() { + let (mut h, [a, b, c, _, x, _]) = harness(); + select(&mut h, &[a, b]); + // B alone (a click on its Lock switch locks the whole selection). + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + h.run_steps(2); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(b)).unwrap().switches.locked && selected == vec![a, b], "{selected:?}"); + let b_position = position(&h, b); + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), c); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), x); + assert_eq!((parent(&h, a), matte(&h, a)), (Some(c), Some((x, MatteKind::Alpha))), "A changed each time"); + assert_eq!((parent(&h, b), matte(&h, b), position(&h, b)), (None, None, b_position), "locked B didn't"); +} + +/// Selected nulls (no Track Matte on their rows) get no matte from another row's menu or pick +/// whip: at the top of the comp one used to refuse the whole mode change (nothing above it to +/// take as a matte), at the bottom it took the layer above as its matte and hid it. +#[test] +fn selected_nulls_get_no_matte() { + let (mut h, [a, b, _, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": x, "kind": "alpha"})).unwrap(); + let n = h.state_mut().session.execute("layer.newNull", json!({"name": "N"})).unwrap()["layer"].as_u64().unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Luma Matte"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, n)], [Some((x, MatteKind::Luma)), Some((x, MatteKind::Luma)), None], "a null on top"); + h.state_mut().session.execute("layer.arrange", json!({"layers": [n], "to": "back"})).unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Alpha Inverted Matte"); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((x, MatteKind::AlphaInverted)), Some((x, MatteKind::AlphaInverted)), None], + "a null at the bottom" + ); + assert!(video(&h, a), "A wasn't hidden as the null's matte"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((y, MatteKind::AlphaInverted)), Some((y, MatteKind::AlphaInverted)), None], + "the pick whip too" + ); +} diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index 2ab27fd6e..f7d349342 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -171,8 +171,8 @@ fn viewer_zooms_and_pans_over_the_track_points() { let zoom = h.state().ui.viewer.zoom; h.input_mut().events.push(egui::Event::PointerMoved(at)); h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 0b0b30720..9e71f8fd7 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -83,3 +83,23 @@ fn a_preset_name_cannot_be_a_built_in_one() { assert!(h.state().auto.find("dialog.comp.presetName").is_none()); assert!(preset_names(&h).is_empty()); } + +/// Cancel and OK stay where they are when switching between Basic, Advanced and 3D Renderer, +/// at any interface scale: the tabs' heights are measured, not assumed. +#[test] +fn composition_settings_buttons_stay_put_between_tabs() { + for zoom in [1.0, 1.3] { + let mut h = harness(); + h.ctx.set_zoom_factor(zoom); + h.run_steps(3); + open_settings(&mut h); + let rects = |h: &Harness<'_, EffectcraftApp>| { + ["dialog.comp.ok", "dialog.comp.cancel", "dialog.comp.name"].map(|id| h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect) + }; + let basic = rects(&h); + for tab in ["advanced", "renderer", "basic"] { + click(&mut h, &format!("dialog.comp.tab.{tab}")); + assert_eq!(rects(&h), basic, "{tab} at {zoom}x"); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..ec8db35cc 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -124,6 +124,44 @@ fn alt_dragging_a_project_item_replaces_the_layer_source() { assert_eq!(layer.name, "Top", "the layer keeps its name"); } +/// Alt/Option-dropping a Project item on the Composition viewer replaces the source of the layer +/// under the pointer, as in the Timeline, instead of adding a layer (#2173). +#[test] +fn alt_dropping_a_project_item_on_the_viewer_replaces_the_layer_source() { + use effectcraft_engine::project::{ItemId, LayerId, LayerSource}; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + // The three solids overlap completely, so the topmost one is under the pointer. + let (top, middle, bottom) = (layer_id(&h, "Top"), layer_id(&h, "Middle"), layer_id(&h, "Bottom")); + let source = |h: &Harness<'_, EffectcraftApp>, id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().source.clone(); + let (middle_before, bottom_before) = (source(&h, middle), source(&h, bottom)); + let before = stack(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + assert_eq!(source(&h, top), LayerSource::Comp { item: ItemId(clip) }, "the layer under the pointer takes the new source"); + assert_eq!(source(&h, middle), middle_before, "the layer below it keeps its source"); + assert_eq!(source(&h, bottom), bottom_before, "the bottom layer keeps its source"); +} + +/// A locked layer under the pointer refuses the Alt-drop; the layer behind it is not replaced +/// instead (#2173). +#[test] +fn alt_dropping_on_a_locked_layer_in_the_viewer_replaces_nothing() { + use effectcraft_engine::project::LayerId; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let top = layer_id(&h, "Top"); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [top], "switch": "locked", "value": true})).unwrap(); + h.run_steps(3); + let sources = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.source.clone()).collect::>(); + let before = sources(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(sources(&h), before, "no layer takes the new source"); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(top)).unwrap().switches.locked); +} + /// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel /// selection (#991). #[test] diff --git a/crates/ui-egui/tests/it/ui_expression_values.rs b/crates/ui-egui/tests/it/ui_expression_values.rs new file mode 100644 index 000000000..b797e6cf6 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_expression_values.rs @@ -0,0 +1,296 @@ +//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression +//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars, +//! every part of a vector (with its separator) and both parts of an angle (with its sign), hovered +//! and dragged too, in dark and light themes. A disabled, removed or empty expression shows the +//! ordinary colour again. The tests read the colour the value text is painted with (the frame's +//! text shapes: the numbers are painted with an override colour, the separator and sign as plain +//! text), and require every expected part to be there. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::theme::ThemeKind; +use egui::epaint::Shape; +use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const TIMELINE: &str = "timeline.prop"; +const EFFECT_CONTROLS: &str = "effectControls.prop"; +const PROPERTIES: &str = "properties.prop"; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Two components (X, Y). + Vector, + /// Revolutions, sign and degrees. + Angle, + Scalar, +} + +/// A property whose value the tests check, and the panels that must show it. +#[derive(Clone, Copy, Debug)] +struct Checked { + uid: u64, + kind: Kind, + panels: &'static [&'static str], +} + +/// A comp with one solid and the Transform effect on it. +struct Ids { + layer: u64, + /// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform + /// effect's Position, Rotation and Opacity (Effect Controls). + driven: [Checked; 6], + /// Transform Scale: no expression. + scale: Checked, +} + +fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "Transform"})).unwrap()["effects"][0].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let g: &PropGroup = layer.props.find_group(fx).unwrap(); + let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + const EFFECT: &[&str] = &[EFFECT_CONTROLS]; + let c = |uid, kind, panels| Checked { uid, kind, panels }; + let ids = Ids { + layer: l, + driven: [ + c(uid("transform/position"), Kind::Vector, LAYER), + c(uid("transform/rotation"), Kind::Angle, LAYER), + c(uid("transform/opacity"), Kind::Scalar, LAYER), + c(fuid("position"), Kind::Vector, EFFECT), + c(fuid("rotation"), Kind::Angle, EFFECT), + c(fuid("opacity"), Kind::Scalar, EFFECT), + ], + scale: c(uid("transform/scale"), Kind::Vector, LAYER), + }; + let transform = layer.props.sub("transform").unwrap().uid; + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.ui.timeline.open_layers.insert(l); + app.ui.timeline.open_groups.insert(transform); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, ids) +} + +fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: &serde_json::Value) { + let mut p = p.clone(); + p["layer"] = json!(ids.layer); + p["prop"] = json!(prop); + h.state_mut().session.execute("prop.setExpression", p).unwrap(); +} + +fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// The area of `c`'s value in `panel`, every expected part required: both components of a vector, +/// the revolutions and degrees of an angle (the separator and sign lie between them). +fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect { + let uid = c.uid; + match c.kind { + Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))), + Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))), + Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")), + } +} + +/// The text painted inside `area` in the last frame, with its colour. +fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> { + fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)), + Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => { + out.push((t.galley.text().trim().to_string(), t.override_text_color.unwrap_or(t.fallback_color))); + } + _ => {} + } + } + let mut out = vec![]; + for c in &h.output().shapes { + walk(&c.shape, area, &mut out); + } + out +} + +/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the +/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar. +fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) { + let shown = texts(h, value_area(h, panel, c)); + let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count(); + let has = |text: &str| shown.iter().any(|(s, _)| s == text); + let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid); + match c.kind { + Kind::Vector => { + assert!(numbers >= 2, "{ctx}: both components"); + if panel == TIMELINE { + assert!(has(","), "{ctx}: the separator"); + } + } + Kind::Angle => { + assert!(numbers >= 2, "{ctx}: revolutions and degrees"); + assert!(has("+") || has("-"), "{ctx}: the sign"); + } + Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"), + } + assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}"); +} + +/// Every checked property in every panel it belongs to. +fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) { + for c in checked { + for panel in c.panels { + assert_value(h, panel, *c, want, what); + } + } +} + +/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue. +#[test] +fn expression_driven_values_are_red_in_every_panel() { + let (mut h, ids) = setup(); + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_ne!(t.expr_text, t.hot_text); + assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); + } +} + +/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of +/// every value is checked again after each change, in both themes. +#[test] +fn expression_state_changes_switch_the_colour() { + let (mut h, ids) = setup(); + let states = [ + (json!({"expression": "value"}), true, "enabled"), + (json!({"enabled": false}), false, "disabled"), + (json!({"enabled": true}), true, "enabled again"), + (json!({"expression": ""}), false, "removed"), + (json!({"expression": "value"}), true, "set again"), + (json!({"expression": " "}), false, "whitespace only"), + ]; + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for (p, red, what) in &states { + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, p); + } + h.run_steps(3); + let want = if *red { t.expr_text } else { t.hot_text }; + assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale")); + } + } +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); +} + +/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red, +/// in every panel and both themes. +#[test] +fn a_hovered_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for c in &opacity { + for panel in c.panels { + let area = value_area(&h, panel, *c); + hover(&mut h, area.center()); + let at = h.ctx.input(|i| i.pointer.hover_pos()); + assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value"); + assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered")); + } + } + // An ordinary value does take the hover accent. + let area = value_area(&h, TIMELINE, ids.scale); + let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center(); + hover(&mut h, x); + let shown = texts(&h, area); + assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}"); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the +/// Timeline and Effect Controls. +#[test] +fn a_dragged_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + let t = h.state().tokens; + for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] { + let at = value_area(&h, panel, c).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0))); + h.step(); + } + assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on"); + assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged")); + let end = at + vec2(12.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Separate Dimensions: X Position and Y Position are separate scalars, so an expression on X Position +/// turns only X red, in the Timeline and Properties panel and both themes; Y Position stays blue. +#[test] +fn separated_position_colours_each_dimension_on_its_own() { + let (mut h, ids) = setup(); + h.state_mut().session.execute("prop.separateDimensions", json!({"layer": ids.layer, "value": true})).unwrap(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| { + let s = &h.state().session; + s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid + }; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + let x = Checked { uid: uid(&h, "transform/positionX"), kind: Kind::Scalar, panels: LAYER }; + let y = Checked { uid: uid(&h, "transform/positionY"), kind: Kind::Scalar, panels: LAYER }; + set_expression(&mut h, &ids, x.uid, &json!({"expression": "value"})); + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_all(&h, &[x], t.expr_text, &format!("{theme:?} X Position (expression)")); + assert_all(&h, &[y], t.hot_text, &format!("{theme:?} Y Position (none)")); + } +} diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 122003abe..141d8ec58 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -106,6 +106,52 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { assert_eq!(doc.style_at(8).font, "Inter"); } +/// A font file of just `name`: the family and Regular in English, and the family again in +/// Simplified Chinese (language 0x804). +fn font_with_native_name(family: &str, native: &str) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let records = [(0x409, 1, family), (0x409, 2, "Regular"), (0x804, 1, native)]; + let strings: Vec> = records.iter().map(|(_, _, s)| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); + let mut name = u16s(&[0, records.len() as u16, 6 + 12 * records.len() as u16]); + let mut off = 0; + for ((lang, id, _), st) in records.iter().zip(&strings) { + name.extend(u16s(&[3, 1, *lang, *id, st.len() as u16, off])); + off += st.len() as u16; + } + strings.iter().for_each(|st| name.extend(st)); + let mut out = u16s(&[1, 0, 1, 0, 0, 0]); + out.extend(b"name"); + out.extend([0u8; 4]); + out.extend(28u32.to_be_bytes()); + out.extend((name.len() as u32).to_be_bytes()); + out.extend(name); + out +} + +/// A family whose own-language name has a character no interface font has is listed under its +/// English name: the harness fails on a missing glyph, and the app would draw boxes. Picking the +/// row still applies the family. +#[test] +fn font_menu_lists_a_name_the_interface_fonts_lack_in_english() { + const FAMILY: &str = "Native Name Menu Test"; + // U+F1234 is in a private use plane that no interface font maps. + const NATIVE: &str = "中文\u{F1234}字体"; + assert_eq!(effectcraft_engine::text::fonts::add_font_data(font_with_native_name(FAMILY, NATIVE)).len(), 1); + assert_eq!(effectcraft_engine::text::fonts::native_families().get(FAMILY).map(String::as_str), Some(NATIVE)); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Native", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + type_text(&mut h, "native name menu"); + let shown: Vec = h.state().auto.query("character.font.option.").iter().map(|e| e.id.clone()).collect(); + assert_eq!(shown, vec![format!("character.font.option.{FAMILY}")]); + click(&mut h, &format!("character.font.option.{FAMILY}")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, FAMILY); +} + /// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at /// 400: a variable font as far as the font menu's filter is concerned. fn pinned_variable_font(family: &str) -> Vec { diff --git a/crates/ui-egui/tests/it/ui_inline_edit.rs b/crates/ui-egui/tests/it/ui_inline_edit.rs index c9cf4693e..6c29045bb 100644 --- a/crates/ui-egui/tests/it/ui_inline_edit.rs +++ b/crates/ui-egui/tests/it/ui_inline_edit.rs @@ -634,6 +634,74 @@ fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() { assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25")); } +/// A primary-button drag from `from` to `to` with `modifiers` held from the press to the release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: with Alt held as it drops, a pick whip refers to an effect's property by its index, +/// `effect("Slider Control")(1)`, as in After Effects; without Alt, by its name. Onto Effect +/// Controls or the Timeline, and into the expression being edited (with a dimension). +#[test] +fn an_alt_pick_whip_refers_to_an_effect_property_by_index() { + let (mut h, _) = harness(); + let (layer, opacity, transform, effects) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let props = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.clone(); + let effects: Vec = std::iter::once(props.sub("effects").unwrap().uid).chain(props.sub("effects").unwrap().groups().map(|g| g.uid)).collect(); + (l, props.sub("transform").unwrap().get("opacity").unwrap().uid, props.sub("transform").unwrap().uid, effects) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().ui.timeline.open_groups.extend(effects); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let (slider, point) = (props(&h).prop("effects/#1/slider").unwrap().uid, props(&h).prop("effects/#2/point").unwrap().uid); + let expr = |h: &Harness<'_, EffectcraftApp>| props(h).find(opacity).unwrap().expr.clone().map(|e| e.text); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let whip = |h: &mut Harness<'_, EffectcraftApp>, target: &str, modifiers: Modifiers| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.propertyPickWhip")), center(h, target)); + drag_with(h, from, to, modifiers); + }; + whip(&mut h, &format!("effectControls.prop.{slider}.value"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto Effect Controls"); + let undo = h.state().session.history.undo.len(); + whip(&mut h, &format!("effectControls.prop.{slider}.value"), Modifiers::NONE); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(\"Slider\")"), "without Alt, by name"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); + whip(&mut h, &format!("timeline.prop.{slider}.name"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto the Timeline"); + // Into the expression being edited, at the cursor, with the value it was dropped on. + let p = center(&h, &format!("timeline.prop.{opacity}.expression")); + click(&mut h, p, 1); + key(&mut h, Key::End); + type_text(&mut h, " + "); + let (from, to) = (center(&h, &format!("timeline.prop.{opacity}.pickWhip")), center(&h, &format!("effectControls.prop.{point}.value.1"))); + drag_with(&mut h, from, to, alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1) + effect(\"Point Control\")(1)[1]")); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "undone in one step"); +} + /// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X /// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so /// they stacked on top of it and seemed to disappear. diff --git a/crates/ui-egui/tests/it/ui_keyframes.rs b/crates/ui-egui/tests/it/ui_keyframes.rs index b47963d85..b137a06fc 100644 --- a/crates/ui-egui/tests/it/ui_keyframes.rs +++ b/crates/ui-egui/tests/it/ui_keyframes.rs @@ -105,11 +105,15 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { _ => None, }) .collect(); - assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + // The system clipboard has the key as After Effects keyframe text (for other applications), + // so it isn't empty and Ctrl+V sends a paste event. + let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; + assert!(text.starts_with("Adobe After Effects 9.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t30\t100\t\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); - h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.input_mut().events.push(Event::Paste(text.clone())); h.run_steps(2); assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); } @@ -190,8 +194,18 @@ fn copy_and_paste_key_subsets_across_position_rotation_and_scale() { assert_eq!(h.state().session.state.selected_props.len(), 3); h.input_mut().events.push(Event::Copy); h.step(); - let note = format!("EffectCraft: {count} keyframes"); - assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e))); + let note = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .unwrap(); + let rows = note.lines().filter(|line| line.strip_prefix('\t').is_some_and(|row| row.starts_with(|c: char| c.is_ascii_digit()))).count(); + assert_eq!(rows, count, "one row per copied key: {note}"); h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0)); let undo_steps = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Paste(note)); diff --git a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs index 1aabdd2bc..73ed95cbd 100644 --- a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs +++ b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs @@ -107,8 +107,8 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { let at = canvas.min + canvas.size() * 0.25; h.input_mut().events.push(Event::PointerMoved(at)); h.input_mut().events.push(Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, @@ -176,3 +176,33 @@ fn roto_brush_in_the_composition_panel_says_where_it_works() { assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); assert_eq!(h.state().ui.focused, PanelKind::Layer); } + +/// A two-finger trackpad scroll pans the layer with any tool and a pinch zooms around the pointer. +#[test] +fn trackpad_scroll_pans_and_pinch_zooms_around_the_pointer() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + let at = canvas.min + canvas.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(60.0, -30.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + let panned = rect(&h, "layerPanel.canvas"); + let moved = panned.min - canvas.min; + assert!((moved.x - 60.0).abs() < 1.5 && (moved.y + 30.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((panned.width() - canvas.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", canvas.width(), panned.width()); + // Pinch: zooms, keeping the point under the pointer. + let at = panned.min + panned.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "layerPanel.canvas"); + assert!((zoomed.width() / panned.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", panned.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_marker_names.rs b/crates/ui-egui/tests/it/ui_marker_names.rs new file mode 100644 index 000000000..99adf41f3 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_marker_names.rs @@ -0,0 +1,97 @@ +//! A marker's name is drawn in a small outlined box beside its flag, in the theme's own field colours (egui_kittest, a real +//! render): the fill and the outline follow Dark and Light, and the two themes differ. `MARKER_NAMES_SNAPSHOT=/abs/dir` +//! also saves the rendered panel for each theme. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::theme::ThemeKind; +use egui_kittest::Harness; +use serde_json::json; + +/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders. +static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn close(got: [u8; 4], want: egui::Color32) -> bool { + got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) +} + +#[test] +fn the_name_box_follows_the_theme() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse"})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(4); + let ctx = h.ctx.clone(); + + let mut seen = vec![]; + for (kind, name) in [(ThemeKind::Dark, "dark"), (ThemeKind::Light, "light")] { + h.state_mut().set_theme(&ctx, kind); + h.run_steps(4); + let tokens = h.state().tokens; + assert_eq!(tokens.kind, kind); + // the flag's hit area is 10 wide, centred on the marker; the box starts 6 points right of the centre + let hit = h.state().auto.find("timeline.marker.0").unwrap_or_else(|| panic!("the marker is on screen ({name})")).rect; + let (x, y0) = (hit[0] + hit[2] / 2.0, hit[1] + 1.0); + let h_flag = (hit[3] - 1.0).min(10.0); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join(format!("marker-name-{name}.png"))).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + h_flag / 2.0; + // inside the box, left of the text: the field fill; on its left edge: the field outline + let fill = at(x + 6.0 + 2.5, mid); + let edge = at(x + 6.0 + 0.5, mid); + assert!(close(fill, tokens.field_bg), "{name}: box fill {fill:?}, the theme's field_bg is {:?}", tokens.field_bg); + assert!(close(edge, tokens.field_border), "{name}: box outline {edge:?}, the theme's field_border is {:?}", tokens.field_border); + seen.push((fill, edge)); + } + assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey"); +} + +/// A duration shorter than the name box ends where the box would sit. The box steps past the end cap, so the duration +/// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it. +#[test] +fn a_short_duration_stays_visible_beside_the_name_box() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse", "duration": 0.1})).unwrap(); + // a second marker half a second later gives the timeline's scale (points per second) + s.execute("markers.set", json!({"new": true, "time": 1.5})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(5); + let tokens = h.state().tokens; + let centre = |id: &str| { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is on screen")).rect; + (r[0] + r[2] / 2.0, r[1] + 1.0, (r[3] - 1.0).min(10.0)) + }; + let ((xa, y0, flag_h), (xb, _, _)) = (centre("timeline.marker.0"), centre("timeline.marker.1")); + let per_second = (xb - xa) / 0.5; + let xe = xa + 0.1 * per_second; + assert!(xe - xa < 30.0, "the duration is shorter than the name box: {} points", xe - xa); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join("marker-short-duration.png")).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + flag_h / 2.0; + // the end cap: a pixel in the marker's colour (new markers are red; the theme's field fill is neutral) within a point + // of where the duration ends, which the box would otherwise cover + let cap = (-1..=1).map(|d| at(xe + d as f32, mid)).max_by_key(|p| i32::from(p[0]) - i32::from(p[1])).unwrap(); + assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the end cap is drawn: {cap:?}"); + // the bar shows left of the cap, not the box's fill + let bar = at(xe - 4.0, mid); + assert!(i32::from(bar[0]) - i32::from(bar[1]) > 20, "the bar shows left of the end cap, not the box: {bar:?}"); + // the box starts after the cap + let fill = at(xe + 3.0 + 2.5, mid); + assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg); +} diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index c76f7fafd..424b75c48 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -65,6 +65,47 @@ fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { h.query_by_label_contains(label).is_some() } +#[test] +fn browse_presets_clears_search_and_scrolls_to_the_visible_presets_list() { + use effectcraft_ui_egui::dock::PanelKind; + + let mut h = harness(); + let ctx = h.ctx.clone(); + let favorites: Vec = effectcraft_engine::effects::all().iter().map(|effect| effect.id.to_string()).collect(); + assert!(!favorites.is_empty(), "the test needs registered effects"); + let recent: Vec = favorites.iter().take(8).cloned().collect(); + + h.state_mut().session.prefs.effects_favorites = favorites; + h.state_mut().ui.effects_recent = recent; + h.state_mut().ui.effects_open.extend(["favorites".into(), "recent".into()]); + h.state_mut().ui.effects_search = "no matching animation preset".into(); + h.state_mut().ui.effects_view.presets = true; + + let managed = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.manage", json!({})); + assert!(managed.is_ok(), "{managed:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert_eq!(h.state().ui.effects_search, "no matching animation preset", "Manage Effects keeps its existing search behavior"); + assert!(h.state().ui.effects_view.presets, "Manage Effects keeps the existing presets setting"); + + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets").is_none(), "the unmatched search hides Animation Presets"); + h.state_mut().ui.effects_view.presets = false; + + let result = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.browsePresets", json!({})); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert!(h.state().ui.effects_search.is_empty()); + assert!(h.state().ui.effects_view.presets); + assert!(h.state().ui.effects_open.contains("presets")); + + h.run_steps(3); + assert!( + h.state().auto.find("effects.category.presets").is_some(), + "Animation Presets must be visible after Browse Presets, even below expanded Favorites and Recently Used" + ); +} + /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the /// preview behind the open menu. #[test] @@ -373,3 +414,63 @@ fn outside_presses_and_focus_loss_close_the_menus() { h.run_steps(2); assert!(!shown(&h, "Open Project"), "losing focus closes the menus"); } + +/// From an open submenu, the pointer reaching another title switches menus too (#1290). +#[test] +fn hovering_a_title_from_an_open_submenu_switches_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + let new = h.query_by(|n| n.label().is_some_and(|l| l.trim_end_matches('\u{23f5}').trim() == "New")).expect("File ▸ New").rect(); + move_to(&mut h, new.center()); + h.run_steps(5); + assert!(shown(&h, "New Project from Template"), "the New submenu is open"); + move_to(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "hovering Edit opened it"); + assert!(!shown(&h, "New Project from Template") && !shown(&h, "Open Project"), "and closed File and its submenu"); +} + +/// A title the pointer can't reach (the app is disabled under a dialog) doesn't switch menus +/// (#1290). +#[test] +fn a_disabled_title_does_not_switch_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); + h.run_steps(2); + move_to(&mut h, edit.center()); + assert!(!shown(&h, "Keyboard Shortcuts"), "Edit didn't open"); +} + +/// A pointer resting on a title doesn't undo Left / Right: only a moving pointer switches +/// (#1290). +#[test] +fn a_resting_pointer_does_not_override_the_arrow_keys() { + if cfg!(target_os = "macos") { + // (Right / Left between menus are tested where the in-window bar is the app's menu.) + return; + } + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + key(&mut h, Key::ArrowRight); + h.run_steps(3); + assert!(shown(&h, "Keyboard Shortcuts"), "Right opened Edit with the pointer still on File"); + assert!(!shown(&h, "Open Project")); +} + +/// Leaving the window keeps the menu open, and coming back in over another title switches to it +/// (#1290): egui reports no pointer movement for that first position. +#[test] +fn pointer_reentering_over_another_title_switches_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + h.input_mut().events.push(Event::PointerGone); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "leaving the window kept File open"); + move_to(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "coming back over Edit opened it"); + assert!(!shown(&h, "Open Project"), "and closed File"); +} diff --git a/crates/ui-egui/tests/it/ui_project_delete.rs b/crates/ui-egui/tests/it/ui_project_delete.rs index f217c29db..2a395cf4b 100644 --- a/crates/ui-egui/tests/it/ui_project_delete.rs +++ b/crates/ui-egui/tests/it/ui_project_delete.rs @@ -364,3 +364,29 @@ fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); } } + +/// An open text edit stands down the Project-panel Clear redirect: the composition being typed +/// into must not be deleted out from under it, with no confirmation when nothing uses it (#2227). +#[test] +fn clear_with_the_project_focused_stands_down_during_a_text_edit() { + let (mut h, _, _) = harness(); + let working = h.state().session.active_comp_id().unwrap(); + // the composition being typed into is also the Project panel's selection, as when it was opened + click(&mut h, &format!("project.item.{}.name", working.0), Modifiers::NONE); + let text = h.state_mut().session.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": text, "created": true})).unwrap(); + h.state_mut().ui.focused = PanelKind::Project; + h.run_steps(2); + assert!(h.state().session.state.project_selection.contains(&working), "the comp is the project selection"); + let project = h.state().session.project.clone(); + + // Edit > Clear (the menu, or the control channel) dispatches through `invoke` + let ctx = h.ctx.clone(); + let cleared = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})); + h.run_steps(3); + + cleared.expect("Clear stands down during a text edit"); + assert!(h.state().session.project.item(working).is_some(), "the composition being typed into survives"); + assert_eq!(h.state().session.project, project, "nothing was deleted"); + assert!(h.state().session.state.text_edit.is_some(), "the text edit stays open"); +} diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 65b429f44..5fc3d8598 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -466,3 +466,31 @@ fn open_comps_are_tabs_in_the_timeline_row_and_the_rest_are_in_its_overflow_menu assert_eq!(open_comps(&h), comps); assert!(has(&h, &format!("panel.tab.Timeline.{hidden}"))); } + +/// Whether `id` was registered in the last frame (not the one before). +fn drawn(h: &Harness<'_, EffectcraftApp>, id: &str) -> bool { + h.state().auto.elements.iter().any(|e| e.id == id) +} + +#[test] +fn an_inactive_comp_tab_shows_its_close_button_on_hover_and_closes_without_showing_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.state.open_comps = vec![ItemId(main), ItemId(pre)]; + h.run_steps(3); + let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))); + // The shown comp's × is always there; Pre's only while the pointer is over its tab. + h.event(Event::PointerMoved(pos2(800.0, 900.0))); + h.run_steps(2); + assert!(drawn(&h, "panel.tab.Timeline.close") && drawn(&h, &format!("panel.tab.Timeline.{main}.close"))); + assert!(!drawn(&h, &format!("panel.tab.Timeline.{pre}.close")), "no × on an inactive tab the pointer is not over"); + h.event(Event::PointerMoved(tp.center())); + h.run_steps(2); + let close = rect(&h, &format!("panel.tab.Timeline.{pre}.close")); + assert!(tp.contains_rect(close), "{close:?} inside {tp:?}"); + // Every comp tab keeps the ×'s room: hovering changes no width. + assert_eq!((rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))), (tm, tp)); + // Its × closes Pre's Timeline; Main stays shown. + click_n(&mut h, close.center(), 1); + assert_eq!(open_comps(&h), vec![main]); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(main))); +} diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 84a218466..9f298ec19 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() { /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a -/// text layer clicked into being and left empty goes away. +/// text layer clicked into being and left empty stays, as it does in After Effects. #[test] fn clicking_another_panel_stops_typing_into_text() { for type_tool in [false, true] { @@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() { assert_eq!(layers(&h), 3, "the Type tool started a text layer"); let at_row = row(&h); click(&mut h, at_row, Modifiers::NONE); - assert_eq!(layers(&h), 2, "left empty, it is removed"); + assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects"); click(&mut h, at, Modifiers::NONE); } else { h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index bc42b4461..c8b45e8da 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -1,7 +1,8 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the -//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items -//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738). +//! layers deselecting them (#205), Mask Feather's linked values (#203) and shape Size's chain link +//! (#2489), copying shape items between shape layers (#227), and Ctrl+V of SVG markup making a shape +//! layer (#738). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -172,6 +173,52 @@ fn mask_feather_values_are_linked_and_not_negative() { assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); } +/// #2489: Rectangle and Ellipse Path Size get the Constrain Proportions chain, off by default: +/// unlinked, W moves alone; linked, H follows W in proportion. Scale on the same layer still starts +/// linked. +#[test] +fn shape_size_chain_starts_unlinked_and_keeps_the_ratio_when_linked() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [100, 50]})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newShape", json!({"kind": "ellipse", "size": [60, 30]})).unwrap()["layer"].as_u64().unwrap(); + let uid = |s: &Session, l: u64, path: &str| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().uid; + let (size, oval, scale) = + (uid(&s, l, "contents/group/contents/rect/size"), uid(&s, o, "contents/group/contents/ellipse/size"), uid(&s, l, "transform/scale")); + let mut h = harness(s); + for (layer, props) in [(l, vec![size, scale]), (o, vec![oval])] { + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["props".into()]); + h.state_mut().ui.timeline.reveal_props.extend(props); + } + h.run_steps(2); + let value = |h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(&format!("@{uid}")).unwrap().value.components() + }; + let type_value = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, size, 0, "80"); + assert_eq!(value(&h, l, size), vec![80.0, 50.0], "a new rectangle's Size starts unlinked"); + type_value(&mut h, oval, 1, "40"); + assert_eq!(value(&h, o, oval), vec![60.0, 40.0], "a new ellipse's Size starts unlinked"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "160"); + assert_eq!(value(&h, l, size), vec![160.0, 100.0], "linked, H follows W in proportion"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "40"); + assert_eq!(value(&h, l, size), vec![40.0, 100.0], "unlinked again, H stays"); + // The ellipse's Size links the same way: 60x40 linked, W to 90 makes H 60. + click(&mut h, &format!("timeline.prop.{oval}.link")); + type_value(&mut h, oval, 0, "90"); + assert_eq!(value(&h, o, oval), vec![90.0, 60.0], "linked, the ellipse keeps its ratio too"); + type_value(&mut h, scale, 0, "50"); + assert_eq!(value(&h, l, scale)[..2], [50.0, 50.0], "Scale still starts linked"); +} + /// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's /// Contents open, Rect, Oval, Rectangle 1's uid). fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) { @@ -190,6 +237,33 @@ fn contents_names(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect() } +/// #959: every item in a shape layer's Contents has an eye switch, as in After Effects; it turns +/// the item off (the renderer skips it), undoably. The group's Transform has none. +#[test] +fn shape_contents_items_have_eye_switches() { + let (mut h, a, _, rect) = two_shape_layers(); + let enabled = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.sub("contents").unwrap().find_group(rect).unwrap().enabled + }; + let eye = format!("timeline.group.{rect}.eye"); + assert!(enabled(&h)); + click(&mut h, &eye); + assert!(!enabled(&h), "the eye turns Rectangle 1 off"); + click(&mut h, &eye); + assert!(enabled(&h), "and on again"); + click(&mut h, &eye); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(enabled(&h), "one undo step"); + // Inside the group: its path and fill have eyes, its Transform doesn't. + h.state_mut().ui.timeline.open_groups.insert(rect); + h.run_steps(3); + let group = h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.find_group(rect).unwrap().clone(); + let transform = group.sub("transform").expect("a shape group has a Transform").uid; + assert!(h.state().auto.find(&format!("timeline.group.{transform}.eye")).is_none(), "no eye on Transform"); + let items = group.sub("contents").expect("the group's items").groups().map(|g| g.uid).collect::>(); + assert!(!items.is_empty() && items.iter().all(|u| h.state().auto.find(&format!("timeline.group.{u}.eye")).is_some()), "every item has an eye"); +} + #[test] fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() { let (mut h, a, b, rect) = two_shape_layers(); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 993925b59..fb7557c83 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -482,8 +482,9 @@ fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { let (from, to) = ([140.0, 100.0], [400.0, 300.0]); let centre = [270.0, 200.0]; - // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. - let corner = [146.0, 106.0]; + // 4 comp px inside the box's top-left corner: inside a rectangle, outside every other kind + // (a rounded corner of 20 px arcs past this point). + let corner = [144.0, 104.0]; for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { let mut h = harness(); h.state_mut().ui.tool = tool; @@ -802,6 +803,34 @@ fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() { assert_ne!(scale(&h), before, "a hidden layer's handle still scales it"); } +/// #1964: Shift-dragging a selected mask vertex keeps it to the axis it moves along most, as a +/// Shift-dragged layer does; without Shift it moves freely. +#[test] +fn shift_drag_keeps_a_mask_vertex_to_one_axis() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let vertex = format!("viewer.mask.{mask}.vertex.0"); + let before = mask_path(&h, "Plate").vertices[0]; + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::SHIFT); + let shifted = mask_path(&h, "Plate").vertices[0]; + assert!(shifted[0] > before[0], "it moved along x: {before:?} -> {shifted:?}"); + assert_eq!(shifted[1], before[1], "and not along y"); + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::NONE); + let free = mask_path(&h, "Plate").vertices[0]; + assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() { @@ -1718,7 +1747,7 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(h.state().session.history.undo.len(), steps + 1); // Dropping a layer where it already is changes nothing. let b = row(&h, "Box").center(); - drag(&mut h, b, b + vec2(0.0, 3.0)); + drag(&mut h, b, b + vec2(20.0, 0.0)); assert_eq!(names(&h), ["Plate", "Box"]); assert_eq!(h.state().session.history.undo.len(), steps + 1); // And back to the top. @@ -1727,6 +1756,335 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(names(&h), ["Box", "Plate"]); } +fn timeline_layer_order(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() +} + +fn timeline_layer_row(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Rect { + rect(h, &format!("timeline.layer.{layer}.row")) +} + +fn start_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerMoved(from + vec2(20.0, 0.0))); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "the row drag started"); +} + +fn release_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn scroll_timeline(h: &mut Harness<'_, EffectcraftApp>, dy: f32) { + let at = rect(h, "timeline.layerMarquee").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); +} + +/// Wheel-scroll far enough to stop drawing the row that owns the held drag, then drop at +/// the other end of the stack. The release must work without that row's Response (#2492). +fn timeline_reorder_after_scroll(down: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let count = if multiple { 2 } else { 1 }; + let moving = if down { before[..count].to_vec() } else { before[before.len() - count..].to_vec() }; + if multiple { + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + } + if !down { + scroll_timeline(&mut h, -10_000.0); + } + let origin = if down { before[0] } else { *before.last().unwrap() }; + let from = timeline_layer_row(&h, origin).center(); + // Setup fills the bounded Undo history; reserve room to count this gesture's steps. + h.state_mut().session.history.clear(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, if down { -10_000.0 } else { 10_000.0 }); + assert!(h.state().auto.find(&format!("timeline.layer.{origin}.row")).is_none(), "the origin row was culled"); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "scrolling keeps the held drag active"); + let target = if down { *before.last().unwrap() } else { before[0] }; + let row = timeline_layer_row(&h, target); + let to = row.center() + vec2(0.0, row.height() * if down { 0.4 } else { -0.4 }); + release_timeline_row_drag(&mut h, to); + + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = if down { expected.len() } else { 0 }; + expected.splice(at..at, moving); + assert_eq!(timeline_layer_order(&h), expected, "the offscreen origin still completes its drop"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one reorder, including a selected group, is one undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "release clears the drag feedback"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + + // A later, independent drag must not reuse the old gesture or selected group. + let i = if down { expected.len() - 1 } else { 0 }; + let j = if down { i - 1 } else { 1 }; + let from = timeline_layer_row(&h, expected[i]).center(); + let row = timeline_layer_row(&h, expected[j]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * if down { -0.4 } else { 0.4 })); + expected.swap(i, j); + assert_eq!(timeline_layer_order(&h), expected, "the next drag is independent"); + assert_eq!(h.state().session.history.undo.len(), steps + 2); +} + +#[test] +fn timeline_rows_drag_to_bottom_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_to_top_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(false, multiple); + } +} + +/// An intermediate scroll must not turn the viewport's lower edge into the stack's end. +fn timeline_reorder_at_viewport_edge(below: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let moving = before[..if multiple { 2 } else { 1 }].to_vec(); + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, moving[0]).center(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, -200.0); + let visible = |h: &Harness<'_, EffectcraftApp>| { + before.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.layer.{id}.row")).is_some()).collect::>() + }; + // Leave space inside the viewport below the last rendered layer's midpoint. + for _ in 0..20 { + let last = timeline_layer_row(&h, *visible(&h).last().unwrap()); + if last.center().y < rect(&h, "timeline.layerMarquee").max.y - 3.0 { + break; + } + scroll_timeline(&mut h, -10.0); + } + let shown = visible(&h); + assert!(!shown.contains(&moving[0]), "the drag owner is culled"); + let last = *shown.last().unwrap(); + assert_ne!(last, *before.last().unwrap(), "layers remain below the viewport"); + let row = timeline_layer_row(&h, last); + let viewport = rect(&h, "timeline.layerMarquee"); + let y = if below { (row.center().y + row.height() * 0.4).min(viewport.max.y - 1.0) } else { row.min.y + row.height() * 0.2 }; + let to = pos2(row.center().x, y); + assert!(viewport.contains(to)); + assert_eq!(to.y > row.center().y, below); + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = expected.iter().position(|id| *id == last).unwrap() + usize::from(below); + expected.splice(at..at, moving); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), expected, "the drop follows the indicated row boundary"); + assert_eq!(h.state().session.history.undo.len(), 1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); +} + +#[test] +fn timeline_rows_drag_after_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_before_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(false, multiple); + } +} + +/// A removed ID must not be reinterpreted as a one-based index by layer.arrange. +fn timeline_reorder_after_deletion(delete_owner: bool) { + let mut h = harness(); + for i in 0..6 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + let before = timeline_layer_order(&h); + let removed = *before.last().unwrap(); + assert!(removed > 0 && removed < before.len() as u64, "the missing ID would also resolve as a surviving layer index"); + let owner = if delete_owner { removed } else { before[0] }; + let moving = if delete_owner { vec![removed] } else { vec![owner, removed] }; + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(3); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, owner).center(); + start_timeline_row_drag(&mut h, from); + if !delete_owner { + // Changing the selection does not change the group captured by the held drag. + h.state_mut().session.execute("layer.select", json!({"layers": [removed]})).unwrap(); + h.run_steps(2); + } + key(&mut h, egui::Key::Delete, Default::default()); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "Delete happened during the held drag"); + let survivors: Vec = before.iter().copied().filter(|id| *id != removed).collect(); + assert_eq!(timeline_layer_order(&h), survivors, "Delete removed only the selected layer"); + let steps = h.state().session.history.undo.len(); + let row = timeline_layer_row(&h, survivors[0]); + release_timeline_row_drag(&mut h, row.center() - vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), survivors, "a stale drag cannot move any surviving layer"); + assert_eq!(h.state().session.history.undo.len(), steps, "release adds no undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, survivors[0]).center(); + let row = timeline_layer_row(&h, survivors[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + let mut expected = survivors; + expected.swap(0, 1); + assert_eq!(timeline_layer_order(&h), expected, "a later independent drag still works"); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_owner_is_deleted() { + timeline_reorder_after_deletion(true); +} + +#[test] +fn timeline_rows_drag_cancels_when_a_group_member_is_deleted() { + timeline_reorder_after_deletion(false); +} + +#[test] +fn timeline_rows_drag_cancels_without_a_primary_release() { + for escape in [false, true] { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + if escape { + key(&mut h, egui::Key::Escape, Default::default()); + } else { + // egui stops its drag on another button's release too. It isn't a layer drop. + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + } + assert_eq!(timeline_layer_order(&h), before, "cancellation must not arrange layers"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a later release must not replay a cancelled drag"); + assert_eq!(h.state().session.history.undo.len(), steps); + } +} + +#[test] +fn timeline_rows_drag_cancels_when_a_modal_opens() { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + let (modifiers, shortcut) = effectcraft_ui_egui::menus::parse_shortcut("Cmd+Alt+;").unwrap(); + key(&mut h, shortcut, modifiers); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings)); + assert!(!h.ctx.read_response(egui::Id::unique(("row", before[0]))).unwrap().enabled(), "the modal disables the Timeline row"); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "the modal opened during the held drag"); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a release behind the modal must not arrange layers"); + assert_eq!(h.state().session.history.undo.len(), steps); + key(&mut h, egui::Key::Escape, Default::default()); + assert!(h.state().dialog.is_none()); + assert_eq!(timeline_layer_order(&h), before, "closing the modal must not replay the drag"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_does_not_replay_a_release_while_hidden() { + use effectcraft_ui_egui::dock::PanelKind; + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().close_panel(PanelKind::Timeline); + h.run_steps(2); + assert!(h.state().auto.find("timeline.layerMarquee").is_none()); + release_timeline_row_drag(&mut h, from); + h.state_mut().show_panel(PanelKind::Timeline); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_composition_changes() { + let mut h = harness(); + let original = h.state().session.active_comp_id().unwrap(); + let before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 4})).unwrap(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Other layer", "color": "#406080", "width": 80, "height": 80})).unwrap(); + let other = h.state().session.active_comp_id().unwrap(); + let other_before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().session.execute("comp.open", json!({"comp": other.0})).unwrap(); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, from); + assert_eq!(timeline_layer_order(&h), other_before); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); +} + fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 { h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0 } diff --git a/crates/ui-egui/tests/it/ui_viewer_trackpad.rs b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs new file mode 100644 index 000000000..55466371a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs @@ -0,0 +1,156 @@ +//! Trackpad gestures in the Composition viewer: a two-finger scroll pans, a pinch (and the mouse +//! wheel, also one that scrolls in pixels) zooms around the pointer (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown }); + h.run_steps(30); +} + +#[test] +fn trackpad_scroll_pans_the_composition() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(80.0, -40.0), Default::default()); + let after = rect(&h, "viewer.comp"); + let moved = after.min - comp.min; + assert!((moved.x - 80.0).abs() < 1.5 && (moved.y + 40.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((after.width() - comp.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", comp.width(), after.width()); +} + +#[test] +fn pinch_and_mouse_wheel_zoom_around_the_pointer() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "viewer.comp"); + assert!((zoomed.width() / comp.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // A mouse wheel (it scrolls in lines) zooms too, and so does Alt + a trackpad scroll. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let wheeled = rect(&h, "viewer.comp"); + assert!(wheeled.width() > zoomed.width() * 1.1, "the wheel zooms in: {} → {}", zoomed.width(), wheeled.width()); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::ALT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), egui::Modifiers::ALT); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < wheeled.width() * 0.9, "Alt + scroll zooms out: {} → {}", wheeled.width(), out.width()); +} + +/// Browsers and some high-resolution wheels report a mouse wheel in pixels, as a trackpad does: +/// it still zooms. +#[test] +fn a_pixel_unit_mouse_wheel_zooms() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > comp.width() * 1.1, "a pixel wheel zooms in: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Shift turns the wheel's scroll sideways: still a wheel, and the next notch zooms. + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::SHIFT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), egui::Modifiers::SHIFT); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + let before = rect(&h, "viewer.comp"); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), Default::default()); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < before.width() * 0.9, "the wheel still zooms: {} → {}", before.width(), out.width()); +} + +/// A straight vertical trackpad scroll pans when the touch phase says it is a trackpad, +/// and after any diagonal scroll; a mouse wheel in lines takes the zoom back. +#[test] +fn a_vertical_trackpad_scroll_pans_once_the_trackpad_is_known() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Start, + }); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 50.0), + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Move, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::End, + }); + h.run_steps(30); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 50.0).abs() < 1.5, "the scroll pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + // Without phases (Windows, Linux, the web) it stays a trackpad. + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 30.0), Default::default()); + let again = rect(&h, "viewer.comp"); + assert!((again.min.y - panned.min.y - 30.0).abs() < 1.5 && (again.width() - comp.width()).abs() < 0.5, "{again:?}"); + // A wheel that scrolls in lines is a mouse: it zooms, and so does a pixel wheel after it. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > again.width() * 1.1, "the wheel zooms: {} → {}", again.width(), zoomed.width()); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + assert!(rect(&h, "viewer.comp").width() > zoomed.width() * 1.1, "a pixel wheel zooms again"); +} + +/// Where the platform names the device (macOS), a straight vertical trackpad scroll pans at once +/// and a wheel in points zooms. +#[test] +fn the_wheel_source_decides_where_the_platform_reports_it() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + let scroll = |h: &mut Harness<'_, EffectcraftApp>, source, dy| { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source, + }); + h.run_steps(30); + }; + scroll(&mut h, egui::MouseWheelSource::Trackpad, 40.0); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 40.0).abs() < 1.5, "the trackpad pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + scroll(&mut h, egui::MouseWheelSource::Wheel, 120.0); + assert!(rect(&h, "viewer.comp").width() > panned.width() * 1.1, "the wheel zooms"); +} diff --git a/crates/ui-egui/tests/it/ui_workspace_focus.rs b/crates/ui-egui/tests/it/ui_workspace_focus.rs index 9bd5374bd..af8ecd9ad 100644 --- a/crates/ui-egui/tests/it/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/it/ui_workspace_focus.rs @@ -16,6 +16,29 @@ fn tab_id(p: PanelKind) -> Id { craft_ui::docking::DockArea::::hit_id(effectcraft_ui_egui::dock_ui::area_id(), &craft_ui::docking::Hit::Tab(p)) } +#[test] +fn workspace_commands_wait_for_migration_without_changing_named_layouts() { + let store = Arc::new(MemoryConfig::default()); + let original = include_str!("../fixtures/workspaces-v1.json"); + store.write("workspaces.json", original).unwrap(); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + assert!(!app.ui.native_outer.is_empty()); + let ctx = egui::Context::default(); + let before = (app.ui.workspace.clone(), app.ui.saved_workspaces.clone(), app.ui.dock.clone()); + for (id, params) in [ + ("window.saveWorkspace", json!({})), + ("window.saveWorkspaceAs", json!({"name": "Too soon"})), + ("window.editWorkspaces", json!({"name": "My Layout", "delete": true})), + ("window.editWorkspaces", json!({"name": "My Layout", "rename": "Too soon"})), + ] { + let error = effectcraft_ui_egui::menus::invoke(&mut app, &ctx, id, params).unwrap_err(); + assert!(error.contains("positions"), "{id}: {error}"); + assert_eq!((app.ui.workspace.clone(), app.ui.saved_workspaces.clone(), app.ui.dock.clone()), before); + assert_eq!(store.read("workspaces.json").as_deref(), Some(original)); + assert_eq!(store.read("workspaces.old.json"), None); + } +} + #[test] fn named_and_current_workspaces_survive_frontend_restart() { let store = Arc::new(MemoryConfig::default()); diff --git a/crates/vp9enc/src/bool.rs b/crates/vp9enc/src/bool.rs index 0cd91b0e1..9eda40541 100644 --- a/crates/vp9enc/src/bool.rs +++ b/crates/vp9enc/src/bool.rs @@ -7,6 +7,9 @@ pub struct BoolEncoder { range: u32, bottom: u32, bit_count: i32, + /// Start of the run of 0xFF bytes at the end of `out` (`out.len()` when it ends in another + /// byte). A carry only touches that run and the byte before it. + ff_start: usize, } impl Default for BoolEncoder { @@ -17,18 +20,37 @@ impl Default for BoolEncoder { impl BoolEncoder { pub fn new() -> Self { - BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24 } + BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24, ff_start: 0 } } + fn push_byte(&mut self, b: u8) { + if b != 255 { + self.ff_start = self.out.len() + 1; + } + self.out.push(b); + } + + /// Carry into the bytes written so far: the trailing 0xFF run becomes 0x00 and the byte + /// before it grows by one (a carry out of the first byte is dropped). Each 0xFF byte is + /// cleared once, so a long run costs O(1) per byte instead of a rescan for every carry. fn add_one(&mut self) { - let mut i = self.out.len(); - while i > 0 { - i -= 1; - if self.out[i] == 255 { - self.out[i] = 0; - } else { - self.out[i] += 1; - return; + let end = self.out.len(); + let start = self.ff_start.min(end); + if let Some(run) = self.out.get_mut(start..) { + run.fill(0); + } + self.ff_start = end; + if let Some(i) = start.checked_sub(1) + && let Some(b) = self.out.get_mut(i) + { + *b = b.wrapping_add(1); + if *b == 255 { + // The grown byte joins the 0xFF bytes before it. + let mut s = i; + while s > 0 && self.out.get(s - 1) == Some(&255) { + s -= 1; + } + self.ff_start = s; } } } @@ -51,7 +73,7 @@ impl BoolEncoder { self.bottom <<= 1; self.bit_count -= 1; if self.bit_count == 0 { - self.out.push((self.bottom >> 24) as u8); + self.push_byte((self.bottom >> 24) as u8); self.bottom &= (1 << 24) - 1; self.bit_count = 8; } @@ -80,7 +102,7 @@ impl BoolEncoder { v <<= 8; } for _ in 0..4 { - self.out.push((v >> 24) as u8); + self.push_byte((v >> 24) as u8); v <<= 8; } self.out @@ -165,4 +187,73 @@ mod tests { assert!(dec.read(1)); } } + + /// The encoder as it was before the carry run was tracked: a carry rescans the bytes. + struct Scan { + out: Vec, + range: u32, + bottom: u32, + bit_count: i32, + } + impl Scan { + fn add_one(&mut self) { + let mut i = self.out.len(); + while i > 0 { + i -= 1; + if self.out[i] == 255 { + self.out[i] = 0; + } else { + self.out[i] += 1; + return; + } + } + } + fn write(&mut self, bit: bool, prob: u8) { + let split = 1 + (((self.range - 1) * prob as u32) >> 8); + if bit { + self.bottom = self.bottom.wrapping_add(split); + self.range -= split; + } else { + self.range = split; + } + while self.range < 128 { + self.range <<= 1; + if self.bottom & (1 << 31) != 0 { + self.add_one(); + } + self.bottom <<= 1; + self.bit_count -= 1; + if self.bit_count == 0 { + self.out.push((self.bottom >> 24) as u8); + self.bottom &= (1 << 24) - 1; + self.bit_count = 8; + } + } + } + } + + #[test] + fn carry_run_tracking_matches_the_rescan() { + let mut seed = 99u32; + for case in 0..300 { + let mut fast = BoolEncoder::new(); + let mut slow = Scan { out: vec![], range: 255, bottom: 0, bit_count: 24 }; + // Skewed probabilities make long 0xFF runs and carries likely. + let extreme = case % 3; + for _ in 0..4000 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let p = match extreme { + 0 => (seed >> 8) as u8 | 1, + 1 => 1 + (seed >> 8) as u8 % 4, + _ => 255 - (seed >> 8) as u8 % 4, + }; + let bit = (seed >> 24) % 256 >= p as u32 || (seed >> 3).is_multiple_of(11); + fast.write(bit, p); + slow.write(bit, p); + assert_eq!(fast.out, slow.out, "case {case}"); + } + } + } } diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs index b9b56b693..12ad77ae7 100644 --- a/crates/vp9enc/src/lib.rs +++ b/crates/vp9enc/src/lib.rs @@ -33,12 +33,17 @@ use probs::Probs; use tables::{AC_QLOOKUP, DC_QLOOKUP}; use transform::Quant; +/// Largest width or height the 16-bit frame size fields can hold. +const MAX_DIMENSION: u32 = 1 << 16; + pub(crate) const BLOCK_8X8: u8 = 3; pub(crate) const BLOCK_16X16: u8 = 6; /// Encoder settings. #[derive(Clone, Debug, PartialEq)] pub struct EncoderConfig { + /// Frame size in samples; the stream stores size - 1 in 16 bits, so values above 65536 are + /// clamped to 65536 by [`Vp9Encoder::new`]. pub width: u32, pub height: u32, /// 1..=100; 100 is lossless. With a target bitrate: the quality of the first frame. @@ -139,7 +144,10 @@ fn quality_to_qidx(q: u8) -> u8 { } impl Vp9Encoder { - pub fn new(cfg: EncoderConfig) -> Self { + pub fn new(mut cfg: EncoderConfig) -> Self { + // The frame header stores width - 1 and height - 1 in 16 bits. + cfg.width = cfg.width.min(MAX_DIMENSION); + cfg.height = cfg.height.min(MAX_DIMENSION); let q = cfg.quality.clamp(1, 100); let lossless = q >= 100 && cfg.target_kbps.is_none(); let base_qidx = if lossless { 0 } else { quality_to_qidx(q) }; @@ -460,3 +468,16 @@ pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec, Vec, Vec = rgba.as_chunks::<4>().0.iter().take(w * h).map(|p| p[3]).collect(); (y, vec![128; cw * ch], vec![128; cw * ch]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_beyond_the_16_bit_header_fields_are_clamped() { + let enc = Vp9Encoder::new(EncoderConfig { width: 70_000, height: u32::MAX, ..Default::default() }); + assert_eq!((enc.cfg.width, enc.cfg.height), (65_536, 65_536)); + let enc = Vp9Encoder::new(EncoderConfig { width: 65_536, height: 1, ..Default::default() }); + assert_eq!((enc.cfg.width, enc.cfg.height), (65_536, 1)); + } +} diff --git a/crates/vp9enc/src/mc.rs b/crates/vp9enc/src/mc.rs index 779a1ea5f..5d897f854 100644 --- a/crates/vp9enc/src/mc.rs +++ b/crates/vp9enc/src/mc.rs @@ -56,6 +56,26 @@ pub fn predict(r: &RefPlane, x16: i32, y16: i32, w: usize, h: usize, out: &mut [ }; let fh = &SUBPEL_REGULAR[fx]; let fv = &SUBPEL_REGULAR[fy]; + if inside && w <= 16 && h <= 16 { + // Rows straight from the reference (no edge clamping needed). + let (xs, ys) = ((x0 - 3) as usize, (y0 - 3) as usize); + let row = |i: usize| &r.data[(ys + i) * r.stride + xs..]; + if fx == 0 && fy == 0 { + for i in 0..h { + out[i * w..i * w + w].copy_from_slice(&row(i + 3)[3..3 + w]); + } + return; + } + let mut tmp = [0u8; 23 * 16]; + for i in 0..h + 7 { + let s = row(i); + filt8(std::array::from_fn(|t| &s[t..]), fh, &mut tmp[i * w..i * w + w]); + } + for i in 0..h { + filt8(std::array::from_fn(|t| &tmp[(i + t) * w..]), fv, &mut out[i * w..i * w + w]); + } + return; + } if fx == 0 && fy == 0 { for i in 0..h { for j in 0..w { @@ -108,24 +128,23 @@ impl PhasePlanes { let rh = ph + 2 * M; let off = (border + M) as i32; let mut raw = vec![0u8; rs * rh]; - for y in 0..rh { + let o = off as usize; + for (y, row) in raw.chunks_exact_mut(rs).enumerate() { let sy = (y as i32 - off).clamp(0, r.h as i32 - 1) as usize; let src = &r.data[sy * r.stride..sy * r.stride + r.w]; - for x in 0..rs { - raw[y * rs + x] = src[(x as i32 - off).clamp(0, r.w as i32 - 1) as usize]; - } + // Edge replication: src[clamp(x - off, 0, w - 1)]. + let (left, rest) = row.split_at_mut(o.min(rs)); + left.fill(src[0]); + let n = r.w.min(rest.len()); + rest[..n].copy_from_slice(&src[..n]); + rest[n..].fill(src[r.w - 1]); } // Horizontal phases over every raw row (inner columns). let hpass = |fx: usize| -> Vec { let f = &SUBPEL_REGULAR[fx]; let mut out = vec![0u8; pw * rh]; - for y in 0..rh { - let row = &raw[y * rs..y * rs + rs]; - for x in 0..pw { - let s = &row[x + M - 3..x + M + 5]; - let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t] as i32).sum::(); - out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; - } + for (row, o) in raw.chunks_exact(rs).zip(out.chunks_exact_mut(pw)) { + filt8(std::array::from_fn(|t| &row[M - 3 + t..]), f, o); } out }; @@ -140,14 +159,8 @@ impl PhasePlanes { } let f = &SUBPEL_REGULAR[fy * 4]; let mut out = vec![0u8; pw * ph]; - for y in 0..ph { - for x in 0..pw { - let mut acc = 64; - for t in 0..8 { - acc += f[t] * hp[(y + M + t - 3) * pw + x] as i32; - } - out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; - } + for (y, o) in out.chunks_exact_mut(pw).enumerate() { + filt8(std::array::from_fn(|t| &hp[(y + M + t - 3) * pw..]), f, o); } planes.push(out); } @@ -167,6 +180,51 @@ impl PhasePlanes { } } +/// `out[x] = clip(Round2(sum_t f[t] * s[t][x], 7))` for 8-bit samples. Lanes are 16-bit sums +/// kept modulo 2^16: with the regular filter every sum plus the rounding term lies in +/// [-13706, 46474], so wrapped non-negative sums stay below 0xC000 and negative ones (clipped +/// to 0) land above it (the decoder's `narrow8`). +#[inline] +fn filt8(s: [&[u8]; 8], f: &[i32; 8], out: &mut [u8]) { + let n = out.len(); + let mut x = 0; + while x + 32 <= n { + filt_chunk::<32>(&s, f, out, x); + x += 32; + } + for w in [16, 8, 4] { + if x + w <= n { + match w { + 16 => filt_chunk::<16>(&s, f, out, x), + 8 => filt_chunk::<8>(&s, f, out, x), + _ => filt_chunk::<4>(&s, f, out, x), + } + x += w; + } + } + for j in x..n { + let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t][j] as i32).sum::(); + out[j] = (acc >> 7).clamp(0, 255) as u8; + } +} + +#[inline(always)] +fn filt_chunk(s: &[&[u8]; 8], f: &[i32; 8], out: &mut [u8], x: usize) { + let mut acc = [64u16; W]; + for t in 0..8 { + let c = f[t] as u16; + let Some(p) = s[t].get(x..).and_then(|p| p.first_chunk::()) else { return }; + for j in 0..W { + acc[j] = acc[j].wrapping_add(c.wrapping_mul(p[j] as u16)); + } + } + if let Some(o) = out.get_mut(x..).and_then(|o| o.first_chunk_mut::()) { + for j in 0..W { + o[j] = if acc[j] < 0xC000 { (acc[j] >> 7).min(255) as u8 } else { 0 }; + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/vp9enc/src/probs.rs b/crates/vp9enc/src/probs.rs index 57af2e29b..c089cb96f 100644 --- a/crates/vp9enc/src/probs.rs +++ b/crates/vp9enc/src/probs.rs @@ -216,7 +216,32 @@ fn inv_remap_prob(delta: usize, prob: u8) -> u8 { /// The `decode_term_subexp` value that moves `old` to `new`. fn delta_for(old: u8, new: u8) -> Option { - (0..255).find(|&d| inv_remap_prob(d, old) == new) + match delta_table().get(old as usize * 256 + new as usize) { + Some(&d) if d != NO_DELTA => Some(d as usize), + _ => None, + } +} + +/// Marks "no delta reaches this probability" in [`delta_table`]. +const NO_DELTA: u8 = 255; + +/// `delta_for` for every (old, new) pair, indexed `old * 256 + new`: the smallest delta that +/// moves `old` to `new`. Filled from the largest delta down so the smallest one wins. +fn delta_table() -> &'static [u8] { + use std::sync::OnceLock; + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| { + let mut t = vec![NO_DELTA; 256 * 256]; + for old in 0..=255u8 { + for d in (0..255usize).rev() { + let new = inv_remap_prob(d, old); + if let Some(e) = t.get_mut(old as usize * 256 + new as usize) { + *e = d as u8; + } + } + } + t + }) } /// Bits of `decode_term_subexp` for `d`. @@ -419,6 +444,16 @@ mod tests { } } + #[test] + fn delta_table_matches_the_linear_search() { + for old in 0..=255u8 { + for new in 0..=255u8 { + let slow = (0..255).find(|&d| inv_remap_prob(d, old) == new); + assert_eq!(delta_for(old, new), slow, "{old} -> {new}"); + } + } + } + #[test] fn inv_map_table_shape() { let t = inv_map_table(); diff --git a/crates/vp9enc/src/tile.rs b/crates/vp9enc/src/tile.rs index 1f0c37d80..082d0bd83 100644 --- a/crates/vp9enc/src/tile.rs +++ b/crates/vp9enc/src/tile.rs @@ -222,6 +222,21 @@ fn probs_defaults() -> &'static probs::Probs { D.get_or_init(probs::Probs::defaults) } +/// SAD of two N×N blocks: per-column 16-bit sums (at most 16 · 255), summed once. +#[inline(always)] +fn sad_n(a: &[u8], sa: usize, b: &[u8], sb: usize) -> u32 { + let mut acc = [0u16; N]; + for i in 0..N { + let (Some(ra), Some(rb)) = (a.get(i * sa..).and_then(|r| r.first_chunk::()), b.get(i * sb..).and_then(|r| r.first_chunk::())) else { + return u32::MAX / 4; + }; + for j in 0..N { + acc[j] += ra[j].abs_diff(rb[j]) as u16; + } + } + acc.iter().map(|&v| v as u32).sum() +} + // ---------------------------------------------------------------- the tile /// Per-frame coding parameters shared by the tiles. @@ -601,12 +616,18 @@ impl<'a> Tile<'a> { let (x16, y16) = (((c * 8) << 4) as i32 + 2 * mv.col, ((r * 8) << 4) as i32 + 2 * mv.row); let mut s = 0u32; if let Some((pl, o, st)) = self.f.phases.as_ref().and_then(|ph| ph.block(x16, y16, n)) { - for i in 0..n { - let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n]; - let b = &pl[o + i * st..o + i * st + n]; - s += a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::(); - } - return s; + let a = &p.src[(r * 8) * p.w + c * 8..]; + let b = &pl[o..]; + return match n { + 8 => sad_n::<8>(a, p.w, b, st), + 16 => sad_n::<16>(a, p.w, b, st), + _ => { + for i in 0..n { + s += a[i * p.w..][..n].iter().zip(&b[i * st..][..n]).map(|(a, b)| a.abs_diff(*b) as u32).sum::(); + } + s + } + }; } // Inter search only runs on frames with a reference. let Some(refs) = self.f.refs.as_ref() else { return u32::MAX / 4 }; @@ -630,7 +651,7 @@ impl<'a> Tile<'a> { let d = &probs_defaults().0; let mb: [u32; 4] = std::array::from_fn(|m| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m as u8)); let mode_bits = |m: u8| mb[(m - NEARESTMV) as usize]; - let cost = |mv: Mv| -> f64 { + let cost_raw = |mv: Mv| -> f64 { let bits = if mv == nearest { mode_bits(NEARESTMV) } else if mv == Mv::ZERO { @@ -642,6 +663,21 @@ impl<'a> Tile<'a> { }; self.sad(r, c, n, mv) as f64 + lambda * bits as f64 }; + // The cost is a pure function of the vector: points the search revisits (the centre it + // came from, candidates on the diamond) are looked up instead of predicted again. + let mut seen = [(Mv::ZERO, 0.0f64); 64]; + let mut nseen = 0usize; + let mut cost = |mv: Mv| -> f64 { + if let Some(&(_, j)) = seen[..nseen].iter().find(|e| e.0 == mv) { + return j; + } + let j = cost_raw(mv); + if nseen < seen.len() { + seen[nseen] = (mv, j); + nseen += 1; + } + j + }; let mut best = (Mv::ZERO, cost(Mv::ZERO)); let prev = self.f.prev_mvs.map(|p| p[r * self.f.mi_cols + c]); for cand in [Some(nearest), Some(near), prev].into_iter().flatten() { diff --git a/crates/vp9enc/src/transform.rs b/crates/vp9enc/src/transform.rs index e6a685d5a..20996d60b 100644 --- a/crates/vp9enc/src/transform.rs +++ b/crates/vp9enc/src/transform.rs @@ -144,6 +144,16 @@ pub fn forward_matrices() -> &'static [[[f64; 16]; 16]; 4] { }) } +/// [`forward_matrices`] transposed: `[type][q][k]`. +fn forward_matrices_t() -> &'static [[[f64; 16]; 16]; 4] { + use std::sync::OnceLock; + static M: OnceLock<[[[f64; 16]; 16]; 4]> = OnceLock::new(); + M.get_or_init(|| { + let f = forward_matrices(); + std::array::from_fn(|t| std::array::from_fn(|q| std::array::from_fn(|k| f[t][k][q]))) + }) +} + fn invert16(m: [[f64; 16]; 16]) -> [[f64; 16]; 16] { let mut a = m; let mut inv = [[0.0; 16]; 16]; @@ -191,12 +201,23 @@ impl Quant { if self.lossless { return forward_wht(res); } - let f = &forward_matrices()[tx_type as usize]; + let ft = &forward_matrices_t()[tx_type as usize]; + // Every output accumulates its 16 products in the same order (q ascending) as a + // per-output sum, but the 16 outputs advance together: 16 independent chains that + // vectorize instead of one serial chain per output. + let mut acc = [-0.0f64; 16]; + for (q, col) in ft.iter().enumerate() { + let r = res[q] as f64; + for k in 0..16 { + acc[k] += col[k] * r; + } + } let mut lv = [0i32; 16]; for k in 0..16 { - let coef: f64 = (0..16).map(|q| f[k][q] * res[q] as f64).sum(); + let coef = acc[k]; let (qs, rnd) = if k == 0 { (self.dcq, self.round_dc) } else { (self.acq, self.round_ac) }; - let l = (coef.abs() / qs as f64 + rnd).floor() as i32; + // Non-negative: truncation is floor. + let l = (coef.abs() / qs as f64 + rnd) as i32; lv[k] = if coef < 0.0 { -l } else { l }; } lv diff --git a/docs/agents.md b/docs/agents.md index d2b691b18..172e5c4c6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -101,7 +101,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | Tool | What it does | |---|---| -| `list_commands {filter?, enabled_only?, schemas?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now. | +| `list_commands {filter?, enabled_only?, schemas?, compact?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now; `compact` lists only ids and labels (and overrides `schemas`). | | `describe_command {command \| id}` | One command in full: label, menu, shortcut, params doc, JSON `schema`, enabled / `why`. | | `execute_command {command \| id, params?}` | Run any command (undoable). Unknown parameter keys are rejected with the accepted list. | | `get_state` | Editor state (active comp, time, selections, tool) plus `app`: version, command and effect counts, export formats, parity summary. | @@ -111,7 +111,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | -| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | +| `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. | | `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | @@ -272,10 +272,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new @@ -540,7 +540,7 @@ indices): character attributes 3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes. 4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly` - work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). + work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name @@ -614,12 +614,21 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a - drop on that value field does. + drop on that value field does. `byIndex: true` (an Alt pick whip: Alt held as it drops) refers + to an effect's properties by their index, `effect("Color Control")(1)`: it still reads a + renamed property, but another one if properties are reordered. * Every property row has a property pick whip in the Parent & Link column (`timeline.prop..propertyPickWhip`; the expression row's is `timeline.prop..pickWhip`). Dragged onto another property's name or value, in the Timeline or Effect Controls (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the property's expression, or is added as one, and the expression is enabled. +* Parent and Track Matte for several layers: on a selected layer's row, the Timeline's Parent + menu and pick whip (`timeline.layer..parent`, `timeline.layer..pickWhip`) and its Track + Matte menu and pick whip (`timeline.layer..trackMatte`, + `timeline.layer..trackMattePickWhip`) set every selected layer's (on an unselected row, + that layer's), in one undo step. Directly: `layer.setParent {layers, parent}` and + `layer.setTrackMatte {layers, matte, kind}`, where `kind: "preserve"` keeps each layer's + own matte mode. * A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset (`prop.reset`: no expression, and the property's default value, an effect parameter's or a Transform property's; an animated property keeps its keyframes and gets the default as a @@ -680,6 +689,19 @@ effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress # progress lines on stderr for a program that reads them ``` +A long `run` doesn't fit on a command line (Windows caps it at 32 767 characters, Linux one +argument at 128 KiB). `run --args-file F` reads the same ` ` arguments one per line; +`run --commands FILE` reads steps as a JSON array of `{"command": ID, "params": {…}}` objects, or one +such object per line (JSON Lines), and `--commands -` reads them from standard input. `--commands` +may be repeated; its steps run after the positional pairs and `--args-file`. A malformed step is a +usage error naming its index (from 0, as in the results) before anything runs, and the error of a step +that fails ends with `(step N)`: + +```sh +effectcraft-cli run --commands steps.json --empty --save-as main.ecproj +generate-steps | effectcraft-cli run main.ecproj --commands - --save --json +``` + Scripts written against After Effects' documented scripting API run with `script`: ```sh diff --git a/docs/architecture.md b/docs/architecture.md index d3dd4db69..4fad74486 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown | | L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, Photoshop and SVG stills, frame cache | | L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model | -| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF | +| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR/Targa sequences, GIF, WAV/AIFF | | L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer | | L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) | | L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express | @@ -345,7 +345,10 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. A frame whose render panics is +the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +validation errors (an invalid texture, often left by a failed allocation): that frame renders on +the CPU and the kept textures are freed, but the compositor stays on and, with no allocation +failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is rendered again on the CPU or released, never left in progress. Native readbacks and timing waits share a finite deadline; failure retires pending readbacks exactly once, and late results are discarded. The desktop executable and browser page own their shared device's error/loss handlers and forward @@ -368,6 +371,13 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every +step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; +the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +checkpoint's births). The rate's keyframes are read at the step's exact layer time +(`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional +stretch), a rate without keyframes or an expression isn't sampled, and a layer of one of them with +expressions isn't layer cached (its key can't see what an expression returned at earlier times). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an adjustment-layer comp. @@ -391,7 +401,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the +collapsed context and are applied while the nested layers are evaluated, so they do not force a +flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened +render. Text layers are always rasterised at their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, @@ -458,8 +471,10 @@ effects crate, hence the `effects → track` edge. `essential` field lists its exposed controls (properties, Media Replacement, groups, comments). Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`, children `eg`), kept in step by `Session::edit`; editing a child overrides it for -that instance. The renderer draws an instance with overrides from a copy of the project with the -overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate` +that instance. A collapsed instance carries its property, Font and Media Replacement overrides +into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`); +other instances are drawn from a copy of the project with the overridden source properties set +(`essential::with_overrides`). Templates are `.ectemplate` ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and `poster.png`; importing offsets every id past the project's (`essential::offset_ids`). **Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c7548835..d9049306c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,7 +103,7 @@ Points are logical window coordinates. A target is `{id}` (the element's centre; | `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | | `ui.move` | `{id \| x,y}` | | `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | -| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.scroll` | `{id \| x,y, dx?, dy?, unit?: point\|line, source?: wheel\|trackpad, pinch?, modifiers?}`: `line` is a mouse wheel (zooms the viewers); `point` (default) is a pixel-precise scroll; `source: trackpad` pans the viewers (as on macOS); with no `source` the viewers take a scroll in points as a wheel until one moves on both axes (a trackpad: from then on it pans); `pinch` is a trackpad pinch's zoom factor | | `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | | `ui.type` | `{text}`: text input into the focused field | @@ -210,7 +210,9 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}`, `resizeSplit {path, size}`; `rect` is `[x, y, width, height]`). It returns `{"docking": workspace}`. `ui.dock.reset` returns the current workspace to its saved or - built-in layout and returns the same. + built-in layout and returns the same. Raw `Open` and `OpenAt` actions are refused in both + `ui.dock {action}` and `window.panel.layout {action}`, as in `window.dockAction`; use the + panel open commands instead. - `window.dockPanel {panel, anchor?, zone?, before?}` docks as `window.panel.dock`: `zone` is `center`, `tab`, `left`, `right`, `top`, `bottom`, `above` or `below`; without an anchor a side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}` diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 83e9a15bc..bd81a09df 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | | **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); Radial Blur, Glow, Venetian Blinds, Radial Wipe, Fractal / Turbulent Noise and Transform are checked the same way (rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | -| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | +| Animation presets | **76** original presets (8 text-animator, 23 Transitions, 23 Backgrounds, 22 Behaviors) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`, `crates/engine/presets/behaviors.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | **Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a @@ -56,7 +56,7 @@ implemented under a generic name ("Key Light"). **≈ 70–130 h**: a fidelity corpus case per effect at default and one non-default setting, compared with After Effects renders, and the fixes they turn up (40–80 h, most of it parallel, but every reference render needs After Effects on the owner's Mac); an original preset library covering -After Effects' preset categories (Behaviors, Shapes, Synthetics, Text, Image effects remain; Backgrounds and +After Effects' preset categories (Shapes, Synthetics, Text, Image effects remain; Backgrounds, Behaviors and Transitions exist) 15–35 h; the open effect bugs 10–20 h. Moving Fractal and the analysis effects to the GPU is performance, not parity. diff --git a/docs/effects.md b/docs/effects.md index b8214e438..c6fb40922 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 308 effects with 3033 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. -- **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 307 implemented in full, 0 partial (what is missing is listed). +- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 308 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -16,6 +16,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | +| 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..0398118c5 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,55 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + +## Rectangle path start point + +Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim +Paths: a plain rectangle starts at its top-right corner and runs clockwise (0–25 % covers the right +edge and 50 px of the bottom); with Roundness 40 it starts where the top-right arc meets the right +edge. Start 70 / End 30 draws 30–70 %. EffectCraft started at the top-left corner (and the rounded +rectangle elsewhere), so trimmed rectangles drew the wrong segment (86 of 5184 grid points off, +now 5 at stroke edges). `effectcraft_path::rect` now draws AE's order, which also gives Convert to +Bezier AE's vertex order. Regression test: `rectangles_start_where_after_effects_starts_them`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ad3b3c2fe..50dff8aff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -48,7 +48,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | -| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output | +| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | | DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | @@ -96,19 +96,19 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | | Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | -## Render queue outputs (`OutputFormat::ALL`, 13) +## Render queue outputs (`OutputFormat::ALL`, 14) H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM; -#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF. +#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR / Targa sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed -single-file video (Animation / PNG codecs), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware +single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxHR, hardware encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file (#325). ## Estimate **≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / -HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h, +HDR / MXF / AVI / DNx import and DPX output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h, relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). ## Revision history diff --git a/docs/footage.md b/docs/footage.md index 4596a8546..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -134,9 +134,10 @@ including files overwritten at the same path and byte size. and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a layer is one layer at a time, as in After Effects. Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. -- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered - file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, - "sequence": false}` for one frame). +- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. A still stays a + still, and a sequence item keeps bringing the new numbered file's run; pass + `sequence: true` to bring a numbered still's whole run (`file.replaceFootage {"path": …, + "sequence": true}`), or `sequence: false` for one frame. - **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the Project panel, puts the item in a new composition of its size, pixel aspect and frame rate, in the same folder, and switches every layer in every composition diff --git a/docs/gaps.md b/docs/gaps.md index ff01df80a..012eb023c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -63,11 +63,11 @@ are grouped by area; the issue tracker has the rest. | 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | -| 7 | **Animation presets: 54 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 54 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops) and read only our `.ecpreset`. Missing: Behaviors, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | @@ -133,6 +133,12 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#947](https://github.com/storytold/effectcraft/issues/947): the + preview startup tests in `ui_audio_scrub`, checked headlessly on Windows with a fake output + device, start an audible preview on an uncached comp. The playhead holds the first frame until + the frames ahead are cached, the sound starts at that frame (from 0 s and from 1 s, also for a + 30 s work area), and a tone lasting only the first 100 ms reaches the output. Video off still + starts at once, no device plays the picture, and Stop while preparing leaves nothing playing. - Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They @@ -163,6 +169,11 @@ The most important missing piece: it turns every other estimate here into a meas a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, and the words, Solid swatches and Alt-click are unchanged. +- Regression evidence for [#1306](https://github.com/storytold/effectcraft/issues/1306) (from + #1072): the `ui_expression_values` tests, checked headlessly on Windows, read the colour the + values are painted in. Expression-driven vectors, angles and scalars are red in the Timeline, + Effect Controls and Properties panel, in dark and light themes, hovered too; disabled, removed + and empty expressions show the ordinary colour. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0a0a85a97..c25da4928 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -23,7 +23,7 @@ monitors are missing, and two GPU start-up failures are open. | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | | Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | -| Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | +| Touch / trackpad gestures | pinch zoom in viewers | Composition and Layer viewers: two-finger scroll pans, pinch zooms around the pointer (Alt + scroll and the mouse wheel zoom too); Timeline pinch zooms time | two-finger scroll pans once it moves diagonally (a pixel-precise wheel keeps zooming); pinch arrives as Ctrl + scroll and zooms | same | same | No rotate or swipe gestures; tested in `ui_viewer_trackpad.rs` (UI logic, not on hardware) | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late after a start offset (#504 open; #554 closed) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | | Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d642af43c..e980fe50f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -22,10 +22,10 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and - Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. Georgian has every row the Chinese catalogs have at this revision (695, as - both Chinese catalogs now have), plus 191 for panels converted to the catalog. + source string, with English fallback. Catalogs exist for Japanese (693 rows), Simplified and + Traditional Chinese (695 and 648 since #2244 pruned the Traditional one), Ukrainian (524), + Brazilian Portuguese (695) and Czech (518), counted at this tree. Georgian has every row the + Simplified Chinese catalog has, plus 191 for panels converted to the catalog. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts @@ -71,14 +71,14 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | -| Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | +| Simplified Chinese | `zh-hans` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the zh source list; no help, effect names translated (307) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | -| Portuguese (Brazil) | `pt-br` | 1,024 (≈ 26%): menus 624 rows, panels 400 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | +| Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,034 (≈ 27%): menus 624 rows, panels 410 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,322 (≈ 34%): menus 629 rows, panels 693 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -87,16 +87,17 @@ Other languages EffectCraft ships (4): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | -| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Traditional Chinese | `zh-hant` | 1,277 (≈ 33%): menus 629 rows, panels 648 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | +| Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | | Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 18%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 29%, Japanese 27%, Brazilian Portuguese 26%, Spanish, French, -German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in +**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese, Traditional Chinese, Japanese and Brazilian Portuguese 34%, Spanish 29%, +French, German and Russian 16%, Korean and Italian 0%; 212 / 10, was ≈ 12% on 10 October; +Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through @@ -111,6 +112,7 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/mcp.md b/docs/mcp.md index 1104927ac..a9f27b959 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ The server provides the same core conventions as FilmCraft #28: | Tool | Arguments | Result | | --- | --- | --- | -| `command_list` | `filter?`, `enabled_only?`, `schemas?` | Command catalog | +| `command_list` | `filter?`, `enabled_only?`, `schemas?`, `compact?` | Command catalog | | `command_run` | `id`, `params?` | Engine command result | | `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | `completed`, `failed`, per-step `results` | | `doc_inspect` | none | Project overview and active composition | diff --git a/docs/plugins.md b/docs/plugins.md index c070e3d98..2117152a1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -12,12 +12,12 @@ Guide): | After Effects | EffectCraft | | |---|---|---| | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | -| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | +| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | -| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | +| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | @@ -41,6 +41,11 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. +* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts + are named `Folder/File.jsx` in `file.scripts.list` and `file.runScript`. One level of folders is + listed; deeper folders are not. `file.uninstallScript` removes only scripts at the top of the + Scripts folders: delete a script in a subfolder yourself. A subfolder may be a link to another + folder (an extension's own), and uninstall never deletes a file through it. ## ScriptUI panels @@ -52,6 +57,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps run at start-up; the list is `scriptui_panels.json` in the settings folder). +To group panels, for example the several panels of one extension, make a subfolder of +`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is +listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it +`My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install +ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it +yourself, and delete them from it yourself too. + Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating palette itself. A minimal panel: diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..2402b9a32 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 5997a8bd1..1d808726a 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -22,7 +22,7 @@ our own scripts; no real-world script library has been run against both apps. | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | -| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | +| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff7cc3ef9..34829d406 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -120,7 +120,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | -| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | +| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); late audio (#504, #335); #329 and #554 are closed; no benchmark against After Effects | diff --git a/docs/testing.md b/docs/testing.md index 67d622fd9..d556fdc4f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,6 +5,10 @@ cargo test --workspace # everything cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, wasm ``` +The `dev` profile keeps debug info to line tables (fast builds, readable backtraces). To debug +interactively — local variables, parameters, column breakpoints — build with the `dev-full` +profile: `cargo run --profile dev-full -p effectcraft -- --demo` (see `CONTRIBUTING.md`). + `cargo xtask ci` builds in the dev profile and runs the tests with `cargo nextest run` (plus `cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. It then runs `missing_translations_report` with `--nocapture`, which prints the interface strings each diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 000000000..2c3add101 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,64 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/effectcraft. The file path is the URL: +`troubleshooting/gpu-not-used.md` → `/docs/effectcraft/troubleshooting/gpu-not-used`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using EffectCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install EffectCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code, a release or an issue. +- Call the product "EffectCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths use the menu text from + `crates/engine/src/menus.rs`. +- Shortcuts: the code writes `Cmd`, which is Ctrl on Windows and Linux. Write Ctrl for Windows. +- Windows 11 is the only Windows version to name. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: EffectCraft does not use my graphics card + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.8.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/effectcraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. Problems and bug reports go in the +[issue tracker](https://github.com/storytold/effectcraft/issues). diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/after-effects-projects.md b/docs/user/faq/after-effects-projects.md new file mode 100644 index 000000000..792aa7cc1 --- /dev/null +++ b/docs/user/faq/after-effects-projects.md @@ -0,0 +1,22 @@ +--- +title: Can EffectCraft open After Effects projects? +description: EffectCraft cannot open .aep or .aepx files, and After Effects plug-ins do not run. It saves .ecproj files and imports Lottie. +order: 10 +verified: 0.9.2 +--- + +No. EffectCraft cannot open After Effects projects (`.aep` or `.aepx`), and After Effects plug-ins do not run in it. + +## What it opens instead + +- Its own projects, saved as `.ecproj`. These are readable, versioned JSON files. +- Lottie animations, with **File ▸ Import ▸ Lottie...**. Each opens as a new composition. +- Photoshop and PDF or Illustrator files, imported as footage or as a composition. See [Import footage and image sequences](../guides/import-footage.md). + +## What carries over + +Expressions are JavaScript with the After Effects object model, such as `wiggle`, `loopOut` and `thisComp.layer("...")`. The panels, shortcuts and effect parameter names follow After Effects, so the skills carry over. + +## Status + +Reading `.aep` files is the largest known gap between EffectCraft and After Effects. It is listed in the project's [gap list](https://github.com/storytold/effectcraft/blob/main/docs/gaps.md), and the maintainers have not yet decided how to approach it. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 000000000..75b27177f --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Short answers about EffectCraft. +order: 40 +--- + +- [Can EffectCraft open After Effects projects?](after-effects-projects.md) +- [Is EffectCraft free, and where does it run?](price-and-platforms.md) diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md new file mode 100644 index 000000000..40f93c60d --- /dev/null +++ b/docs/user/faq/price-and-platforms.md @@ -0,0 +1,24 @@ +--- +title: Is EffectCraft free, and where does it run? +description: EffectCraft is free and open source under MIT or Apache-2.0, and runs on Windows, macOS, Linux and in the browser. +order: 20 +verified: 0.9.2 +--- + +Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2.0, at your option. It has no telemetry, and it uses no network access unless you ask for it. + +## Where it runs + +| Platform | Builds | +|----------|----------------------------------------------------------------------------------------| +| Windows | x64, arm64 and 32-bit x86, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 deb, rpm, tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | + +See [Install EffectCraft](../getting-started/install.md) for the files. + +## How mature is it? + +EffectCraft is in alpha. The README calls macOS the most tested platform, and says Linux and Windows users have hit basic interaction bugs that are being fixed first. Bug reports in the [issue tracker](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 000000000..6a3cad148 --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,8 @@ +--- +title: Getting started +description: Install EffectCraft and make your first composition. +order: 10 +--- + +- [Install EffectCraft](install.md) +- [Make your first composition](../guides/make-a-composition.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 000000000..b2e77a5d9 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,58 @@ +--- +title: Install EffectCraft +description: Download EffectCraft for Windows 11, macOS or Linux, install it, or install it through ArtCraft Launcher. +order: 10 +verified: 0.9.2 +--- + +The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.9.2`. + +## Windows 11 + +1. Open the [latest release](https://github.com/storytold/effectcraft/releases/latest). +2. Download `effectcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). +3. Run the file and follow the installer. It asks where to install EffectCraft and offers a **Create a desktop shortcut** box. +4. Start **EffectCraft** from the Start menu. + +The release also has an `arm64` installer for Snapdragon and other ARM PCs. The installers are code-signed and install for all users. Later versions install into the same folder. + +To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` build has a portable zip too. + +There are also 32-bit `x86` builds, as an installer and a portable zip. + +## macOS + +1. Download `effectcraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs and is signed and notarized. +2. Open it and drag EffectCraft to Applications. + +The command-line tool is a separate download for macOS: `effectcraft-cli--macos-universal.zip`. + +## Linux + +Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. + +| File | How to use it | +|-----------------------------------------------|------------------------------------------| +| `effectcraft--linux-x86_64.AppImage` | Run `chmod +x` on the file, then run it. | +| `effectcraft--linux-x86_64.deb` | For Debian and Ubuntu. | +| `effectcraft--linux-x86_64.rpm` | For Fedora, RHEL and openSUSE. | +| `effectcraft--linux-x86_64.flatpak` | `flatpak install --user ` | +| `effectcraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +For RISC-V 64-bit there is a `.deb`, an `.rpm` and a `.tar.gz` (`riscv64` in the file name). They need glibc 2.39 or newer. There is also a FreeBSD x86_64 `.tar.gz`. + +## In the browser + +`effectcraft-web-.zip` is the web build. It is a static site that runs in a modern browser and needs to be hosted on a web server. Projects, media and settings stay in the browser's private storage and on your computer. + +## Install through ArtCraft Launcher + +ArtCraft Launcher lists EffectCraft. Click **Install** on its card. See [Install and update Craft apps](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps). + +## Check the download + +`SHA256SUMS.txt` on the release page lists a checksum for every file. + +## Next + +Open EffectCraft and make something: [Make your first composition](../guides/make-a-composition.md). If you only want to look around, choose **Open Demo Project** on the Home screen. diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md new file mode 100644 index 000000000..5459b21d9 --- /dev/null +++ b/docs/user/guides/add-an-effect.md @@ -0,0 +1,35 @@ +--- +title: Add an effect +description: Apply an effect such as Gaussian Blur to a layer and change its settings in Effect Controls. +order: 30 +verified: 0.9.2 +--- + +Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + +## Steps + +1. Select a layer in the Timeline. +2. Apply an effect in one of two ways: + - Choose it from the **Effect** menu. Effects are grouped by category, for example **Effect ▸ Blur & Sharpen ▸ Gaussian Blur**. + - Open **Window ▸ Effects & Presets** (Ctrl+5). Type in the search field, then double-click the effect. Dragging it onto a layer works too. +3. Open **Window ▸ Effect Controls** (F3), or choose **Effect ▸ Effect Controls**. The effect's settings are listed there. **Gaussian Blur** has **Blurriness**, **Blur Dimensions** and **Repeat Edge Pixels**. +4. Change a value. Click the stopwatch beside it to animate it, as in [Animate with keyframes](animate-with-keyframes.md). + +If no layer is selected when you double-click an effect, EffectCraft shows **Select a layer to apply to**. Select a layer and try again. + +## Remove or reuse effects + +- **Effect ▸ Last Effect** applies the effect you used last. +- **Effect ▸ Remove All** takes every effect off the selected layers. +- In **Effects & Presets**, click the star on an effect to add it to Favorites. Recently used effects are listed too. + +## Layer styles + +For drop shadows, glows and bevels, use **Layer ▸ Layer Styles**. It lists **Drop Shadow**, **Inner Shadow**, **Outer Glow**, **Inner Glow**, **Bevel and Emboss** and **Satin**, among others. + +## Next + +[Render a video](render-a-video.md). diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md new file mode 100644 index 000000000..53ef7882e --- /dev/null +++ b/docs/user/guides/animate-with-keyframes.md @@ -0,0 +1,35 @@ +--- +title: Animate with keyframes +description: Fly a layer in with two Position keyframes and smooth the motion with Easy Ease. +order: 20 +verified: 0.9.2 +--- + +Click the stopwatch beside a property to start animating it, move the current-time indicator, then change the value. The second keyframe appears by itself. Select both keyframes and press F9 to ease the motion. + +You need a composition with a layer. See [Make your first composition](make-a-composition.md). + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Steps + +1. In the Timeline, twirl the layer open, then open **Transform**. +2. Set the starting value. For a layer that flies in from the left, scrub or type a **Position** that is outside the frame. +3. Click the stopwatch beside **Position**. It turns blue and records a keyframe at the current time. +4. Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler. +5. Drag the layer to its final place in the Composition viewer, or scrub the **Position** value. Because the stopwatch is on, a second keyframe appears by itself. +6. Select both keyframes (drag a box around them, or click the **Position** name) and press F9. This is **Animation ▸ Keyframe Assistant ▸ Easy Ease**. The layer now glides in and settles. +7. Press the Spacebar, or use Play in the Preview panel, to watch it. Press it again to stop. + +## Other things you can do + +- Click the keyframe button in the layer's property row to add or remove a keyframe at the current time. Its tooltip reads **Add or remove keyframe**. +- Choose **Animation ▸ Keyframe Assistant ▸ Easy Ease In** (Shift+F9) or **Easy Ease Out** to ease one side only. +- Choose **Animation ▸ Toggle Hold Keyframe** for a keyframe that jumps instead of moving. +- Choose **Animation ▸ Keyframe Interpolation...** or **Keyframe Velocity...** to set the curve exactly. +- Hold Alt and click a stopwatch to add an expression. +- The Graph Editor shows value and speed graphs with draggable handles. + +## Next + +[Add an effect](add-an-effect.md). diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md new file mode 100644 index 000000000..2cd754bd2 --- /dev/null +++ b/docs/user/guides/import-footage.md @@ -0,0 +1,49 @@ +--- +title: Import footage and image sequences +description: Bring video, images and numbered image sequences into an EffectCraft project, and change how footage is read. +order: 50 +verified: 0.9.2 +--- + +Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Import files + +1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. +2. Photoshop and PDF or Illustrator files first ask whether to import them as footage or as a composition. +3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. If no composition is open, dropping it on the empty viewer makes a new composition from it. + +You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. A dropped Photoshop file asks how to import it, as **File ▸ Import ▸ File...** does. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. + +## Import an image sequence + +1. Choose **File ▸ Import ▸ File...** and pick any one numbered file of the run. +2. In the **Import Image Sequence** dialog, check the run it found. It also lists missing frames. +3. Tick the sequence option, which is named after the file type, for example **PNG Sequence**. When it is off, the files import as separate stills. It is off by default. +4. Set **Frame rate (fps)**. The default comes from **Sequence Footage** on the Import page of the preferences (**Edit ▸ Preferences ▸ Import...**; on macOS: **EffectCraft ▸ Settings... ▸ Import...**), which is 30 fps. +5. Confirm. The run arrives as one footage item named like `shot_[0001-0250].png`. + +**Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. + +Stills in PNG, JPEG, TIFF, OpenEXR, BMP, WebP, Targa (`.tga`), Radiance HDR (`.hdr`), DPX and Cineon (`.cin`) can form a sequence. PSD, SVG and PDF files import one by one. + +If the numbering skips a frame, EffectCraft shows colour bars for it. Put the missing files in the folder and choose **File ▸ Reload Footage** to fill the gap. + +## Change how footage is read + +Select the footage in the Project panel and choose **File ▸ Interpret Footage ▸ Main...** (Ctrl+Alt+G). The dialog has **Alpha**, **Assume this frame rate** and **Start Timecode**, among others. A change applies everywhere the footage is used. + +For example, a 250-frame sequence at 25 fps lasts 10 seconds. At 24 fps it lasts 10.4 seconds. + +## Replace or reload footage + +- **File ▸ Replace Footage ▸ File...** (Ctrl+H) points the item at another file. +- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder. + +If footage shows as missing, see [Footage is missing or offline](../troubleshooting/footage-missing.md). + +## Linux with Wayland + +Dragging files onto the window does not work on native Wayland sessions. Use **File ▸ Import ▸ File...** instead. diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 000000000..d8182d069 --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Guides +description: Step-by-step guides for the main tasks in EffectCraft. +order: 20 +--- + +- [Make your first composition](make-a-composition.md) +- [Animate with keyframes](animate-with-keyframes.md) +- [Add an effect](add-an-effect.md) +- [Render a video](render-a-video.md) +- [Import footage and image sequences](import-footage.md) diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md new file mode 100644 index 000000000..5dcd8702b --- /dev/null +++ b/docs/user/guides/make-a-composition.md @@ -0,0 +1,34 @@ +--- +title: Make your first composition +description: Create a composition, add a text layer and save the project as an .ecproj file. +order: 10 +verified: 0.9.2 +--- + +Choose **Composition ▸ New Composition...**, set the size, frame rate and duration, click **OK**, then add layers from the **Layer ▸ New** menu. Shortcuts below use Ctrl on Windows and Linux. On macOS, use Cmd. + +## Steps + +1. Choose **Composition ▸ New Composition...** (Ctrl+N). On the Home screen, click **New Composition** instead. +2. In the dialog, type a **Composition Name**. The default is `Comp 1`. +3. Pick a **Preset**, or set **Width**, **Height**, **Frame Rate** and **Duration**. The presets include `HDTV 1080 24`, `UHD 4K 25` and `Social Media Square · 1080x1080 · 30 fps`. +4. Click **OK**. The composition opens in the Composition panel and the Timeline. +5. Choose **Layer ▸ New ▸ Text** and type your text. A text layer appears at the top of the Timeline, and its name follows what you type. +6. Add more layers from **Layer ▸ New**: **Solid...**, **Shape Layer**, **Null Object**, **Adjustment Layer**, **Light...** and **Camera...**. +7. Choose **File ▸ Save** to save the project. + +Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it also works with version control. + +## Change the settings later + +Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. + +To keep a size and frame rate for later, click the **+** button next to **Preset** (**Save Preset…**) and name it. Your presets are listed above the built-in ones, in this dialog and in **New Composition...**. To remove one, select it and click the bin button (**Delete Preset**). + +## Learn in the app + +The in-app tutorial **Animate a title** walks through a similar title animation. To open it, choose **Help ▸ In-App Tutorials...**. + +## Next + +[Animate with keyframes](animate-with-keyframes.md). diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md new file mode 100644 index 000000000..42fa5d0a5 --- /dev/null +++ b/docs/user/guides/render-a-video.md @@ -0,0 +1,52 @@ +--- +title: Render a video +description: Add a composition to the Render Queue, choose a format such as H.264, pick where to save and click Render. +order: 40 +verified: 0.9.2 +--- + +Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Steps + +1. Select the composition in the Project panel or open it. +2. Choose **Composition ▸ Add to Render Queue** (Ctrl+M). You can also drag compositions onto the Render Queue panel. Open the panel with **Window ▸ Render Queue** (Ctrl+Alt+0). +3. The queue lists the item with its **Comp Name**, **Status**, **Started** and **Render Time**. Twirl the item open to see **Render Settings:**, **Output Module:** and **Output To**. +4. Click **Output Module:** and choose a format. The menu entries read **Format: H.264**, **Format: QuickTime (ProRes)** and so on. +5. Click the file name next to **Output To** to choose where to save. Alt-click it to edit the name template instead. +6. Check that the **Render** tick box at the left of the item is on. It is on by default, and only ticked items render. +7. Click **Render**. The status changes to **Rendering** with a percentage. Click **Stop** to cancel. + +Tick **Notify** to get a sound and a notification when the queue finishes. + +## Formats + +The **Output Module:** menu lists the formats your build can export: + +| Menu entry | File type | +|--------------------------|-----------------------------| +| H.264 | `.mp4` | +| QuickTime (ProRes) | `.mov` | +| QuickTime (Uncompressed) | `.mov` | +| HEVC (H.265) | `.mp4` | +| AV1 | `.mp4` | +| WebM (VP9 + Opus) | `.webm` | +| PNG Sequence | `.png`, one file per frame | +| JPEG Sequence | `.jpg`, one file per frame | +| TIFF Sequence | `.tif`, one file per frame | +| OpenEXR Sequence | `.exr`, one file per frame | +| Animated GIF | `.gif` | +| WAV | `.wav`, audio only | +| AIFF | `.aif`, audio only | + +To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes), QuickTime (Uncompressed) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. + +## Render only part of a composition + +Click **Render Settings:** and choose **Time Span: Work Area Only** or **Time Span: Length of Comp**. The same menu sets **Quality: Best** or **Quality: Draft** and the resolution (**Resolution: Full**, **Half**, **Third** or **Quarter**). + +## Export a Lottie animation instead + +Choose **File ▸ Export ▸ Lottie JSON...** to write a composition as `.json` or `.lottie`. EffectCraft lists anything Lottie cannot express. To open one, choose **File ▸ Import ▸ Lottie...**. diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 000000000..05ed4993c --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,36 @@ +--- +title: EffectCraft +description: EffectCraft is a free, open-source app for motion graphics and visual effects, an alternative to Adobe After Effects. +verified: 0.9.2 +--- + +EffectCraft is a free, open-source app for animated titles, motion graphics and compositing. It is an alternative to Adobe After Effects. + +You build a composition from layers (solids, shapes, text, footage, other compositions), animate their properties with keyframes, add effects and render the result. The panels and shortcuts follow After Effects: Project, Composition, Timeline, Effect Controls, Effects & Presets and the Render Queue. It runs on macOS, Windows and Linux, and in the browser. + +EffectCraft is young and in alpha. It cannot open After Effects projects (`.aep`), and some things still behave differently from After Effects. See [Can EffectCraft open After Effects projects?](faq/after-effects-projects.md). + +## Start here + +- [Install EffectCraft](getting-started/install.md) +- [Make your first composition](guides/make-a-composition.md) +- [Animate with keyframes](guides/animate-with-keyframes.md) +- [Add an effect](guides/add-an-effect.md) +- [Render a video](guides/render-a-video.md) +- [Import footage and image sequences](guides/import-footage.md) + +## When something is wrong + +- [EffectCraft does not use my graphics card](troubleshooting/gpu-not-used.md) +- [Footage is missing or offline](troubleshooting/footage-missing.md) + +## Sections + +- [Getting started](getting-started/index.md) +- [Guides](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which version and which system you use. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md new file mode 100644 index 000000000..7507a2ad0 --- /dev/null +++ b/docs/user/troubleshooting/footage-missing.md @@ -0,0 +1,35 @@ +--- +title: Footage is missing or offline +description: Find missing footage in an EffectCraft project, relink it to a folder or replace it with another file. +order: 20 +verified: 0.9.2 +--- + +Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Symptoms + +- A footage item in the Project panel is flagged as missing after you open a project. +- EffectCraft shows a message that footage items are missing. + +## Fix + +1. Choose **File ▸ Dependencies ▸ Find Missing Footage**. EffectCraft selects every missing item in the Project panel and shows how many it found. +2. Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder that holds your files. EffectCraft searches that folder and its subfolders. +3. It relinks files whose names are unique and whose type, dimensions and codec match what the project saved. The whole relink is one undo step. +4. For anything left over, select the item and choose **File ▸ Replace Footage ▸ File...** (Ctrl+H), then pick the file yourself. This also covers files with duplicate names in the folder, which Relink leaves alone. +5. After you copy or render files into a folder, choose **File ▸ Reload Footage** (Ctrl+Alt+L) to read them again. + +Relinking keeps item names, interpretation and layer timing. It does not change your files. For image sequences, every saved frame must be in the new folder. Relink Missing Footage is not available in the browser version. + +## Why this happens + +A project stores where each footage file is. If you move or rename the files, or open the project on another computer, the project cannot find them. + +Projects built with the command-line tool or an agent can use relative paths such as `bg.mp4`. Before 0.8.0, the app looked for those in its own working directory instead of next to the project file. Since 0.8.0 it also looks next to the project file ([pull request #676](https://github.com/storytold/effectcraft/pull/676)). The reporter on macOS has not confirmed the fix yet ([issue #652](https://github.com/storytold/effectcraft/issues/652)). + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which system you use, whether the project came from another computer or from the command-line tool, and where the footage is stored. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md new file mode 100644 index 000000000..7c4fb29e0 --- /dev/null +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -0,0 +1,42 @@ +--- +title: EffectCraft does not use my graphics card +description: What to do when the report says GPU none (CPU compositing), the window picked another adapter, or GPU effects fail on Windows 11 with DirectX 12. +order: 10 +verified: 0.9.2 +--- + +Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences (**Edit ▸ Preferences ▸ Startup & Repair...**; on macOS: **EffectCraft ▸ Settings... ▸ Startup & Repair...**) and restart EffectCraft. + +## Symptoms + +- The report says `GPU: none (CPU compositing)` followed by a reason. +- The `Window graphics:` line names an adapter or backend you did not expect, for example an integrated or OpenGL adapter instead of your graphics card. + +## Fix + +1. Choose **Help ▸ System Compatibility Report...**. Copy the text. +2. Look at the `Window graphics:` line. If it shows the wrong adapter, continue. If it shows your card, go to **Still stuck?** below. +3. Choose **Edit ▸ Preferences ▸ Startup & Repair...**. On macOS, choose **EffectCraft ▸ Settings... ▸ Startup & Repair...**. +4. Set **Window Graphics** to **Automatic**. The other choice is **OpenGL (compatibility)**. +5. Close EffectCraft and start it again. The change applies from the next launch. +6. Open the report again and check that `Window graphics:` now names your card. + +One user whose GPU was not detected on 0.8.0 fixed it this way: they set the window graphics to Automatic, and EffectCraft started through Vulkan ([issue #596](https://github.com/storytold/effectcraft/issues/596)). + +If the window runs on your card but the report still says `GPU: none (CPU compositing)`, the reason after it says why the GPU compositor did not start. With **OpenGL (compatibility)**, compositing always runs on the CPU, because the GPU compositor needs DirectX 12, Vulkan, Metal or WebGPU. + +## Why this happens + +When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL by itself. That keeps the app usable. Setting **Window Graphics** to **Automatic** lets EffectCraft choose the graphics backend again. + +The **GPU Information...** button on the Previews page of the preferences names the adapter the GPU compositor runs on. When compositing runs on the CPU, it says why after `GPU compositing is off:`. It also shows the `Window graphics:` line. For a bug report, still send the whole System Compatibility Report. + +## Windows 11 with DirectX 12 + +On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is still open in 0.9.2 as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. + +The window can also take a long time to appear. On 0.8.0 with an RTX 5060 Ti, one user measured 69 seconds before the window showed on DirectX 12 without a working DXC shader compiler, against 3 seconds on Vulkan, which is what Automatic picked on that PC. If you close EffectCraft during that wait, the next launch uses OpenGL and has no GPU compositor until you set **Window Graphics** back to **Automatic** (steps above). So wait for the window on the first launch. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Include the whole System Compatibility Report, your graphics card and driver version, and whether you also have an integrated GPU. diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 000000000..0cf140136 --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,10 @@ +--- +title: Troubleshooting +description: Fixes for common EffectCraft problems. +order: 30 +--- + +- [EffectCraft does not use my graphics card](gpu-not-used.md) +- [Footage is missing or offline](footage-missing.md) + +Not listed? Open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues) or ask in [Discord](https://discord.gg/artcraft). Include the text from **Help ▸ System Compatibility Report...**. diff --git a/docs/web.md b/docs/web.md index c18f7e869..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"last"` (or no key) | the last visit's project; the demo project on a first visit | +| `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | + +The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown +value, is the default. The file is fetched once per page load; serve it with +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 6f6eadae5..9f4ea2185 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -18,6 +18,8 @@ // segment on that side. So a selected pair gets the whole curve, and a single key works too. // Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease // set on them wouldn't play. +// The built-in Linear preset uses Linear interpolation, including on unused endpoint sides +// so a fully linear key has a diamond icon; the other presets use Bezier. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -29,6 +31,7 @@ var MAX_NAME = 64; var MIN_INFLUENCE = 0.1; var MAX_SPEED = 100; + var LINEAR = KeyframeInterpolationType.LINEAR; var BEZIER = KeyframeInterpolationType.BEZIER; var HOLD = KeyframeInterpolationType.HOLD; @@ -178,16 +181,16 @@ // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). - function easeSide(p, k, out, eases) { + function easeSide(p, k, out, eases, interpolation) { var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); if (out) { p.setTemporalEaseAtKey(k, inEase, eases); - p.setInterpolationTypeAtKey(k, inType, BEZIER); + p.setInterpolationTypeAtKey(k, inType, interpolation); } else { p.setTemporalEaseAtKey(k, eases, outEase); - p.setInterpolationTypeAtKey(k, BEZIER, outType); + p.setInterpolationTypeAtKey(k, interpolation, outType); } } @@ -201,7 +204,7 @@ // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. - function applyCurve(c, mode) { + function applyCurve(c, mode, interpolation) { var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. @@ -214,16 +217,16 @@ continue; } var s = doIn && k > 1 ? slopes(p, k - 1) : null; - if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + if (s || (doIn && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: false, eases: s ? eases(s, c.inSpeed, c.inInfluence) : p.keyInTemporalEase(k) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; - if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (s || (doOut && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: true, eases: s ? eases(s, c.outSpeed, c.outInfluence) : p.keyOutTemporalEase(k) }); if (sides.length > before) keys++; } } if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases, interpolation); } finally { app.endUndoGroup(); } @@ -384,7 +387,10 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var r = applyCurve(c, m), n = r.eased; + // Linear is an interpolation type too, so its keys get diamond icons. Edited speeds + // make a custom Bezier curve even while Linear remains selected in the list. + var interpolation = list.selection && list.selection.text === "Linear" && c.outSpeed === 1 && c.inSpeed === 1 ? LINEAR : BEZIER; + var r = applyCurve(c, m, interpolation), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..915f6ef3e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791594108, + "narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6510408d147d8c5e6d76043e899e9251756f6a75", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..38d8aa87c --- /dev/null +++ b/flake.nix @@ -0,0 +1,106 @@ +{ + description = "EffectCraft — motion graphics and visual effects, a free alternative to After Effects, in pure Rust"; + + inputs = { + # EffectCraft needs edition 2024 and rustc ≥ 1.95, so the flake tracks unstable. Consumers on a + # release channel can retarget it: `inputs.effectcraft.inputs.nixpkgs.follows = "nixpkgs";` + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { + self, + nixpkgs, + }: + let + inherit (nixpkgs) lib; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + # The AppStream metadata carries a release date. The flake source's own commit date is + # reproducible, unlike "now"; a tarball or dirty tree without one falls back to the epoch. + buildDate = + let + d = self.lastModifiedDate or ""; + in + if builtins.stringLength d >= 8 then + "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}" + else + "1970-01-01"; + + packageArgs = { + inherit buildDate; + }; + + forAllSystems = f: lib.genAttrs systems (system: f (import nixpkgs { inherit system; }) system); + in + { + packages = forAllSystems ( + pkgs: _: + let + effectcraft = pkgs.callPackage ./nix/package.nix packageArgs; + in + { + inherit effectcraft; + default = effectcraft; + } + ); + + # `nixpkgs.overlays = [ inputs.effectcraft.overlays.default ]` → `pkgs.effectcraft` everywhere. + overlays.default = final: _prev: { + effectcraft = final.callPackage ./nix/package.nix packageArgs; + }; + + apps = forAllSystems ( + _: system: { + default = { + type = "app"; + program = lib.getExe self.packages.${system}.effectcraft; + }; + } + ); + + devShells = forAllSystems ( + pkgs: system: + let + effectcraft = self.packages.${system}.effectcraft; + in + { + default = pkgs.mkShell ({ + # The package's own native inputs, so `cargo build` in the shell needs no extra setup. + inputsFrom = [ effectcraft ]; + packages = with pkgs; [ + cargo + rustc + clippy + rustfmt + rust-analyzer + ]; + # `cargo run` dlopens the Vulkan loader, libwayland and libxkbcommon like the packaged binary. + LD_LIBRARY_PATH = lib.makeLibraryPath effectcraft.passthru.runtimeLibs; + RUST_BACKTRACE = "1"; + }); + } + ); + + checks = forAllSystems ( + pkgs: system: { + effectcraft = self.packages.${system}.effectcraft; + + # `nix fmt` formats the flake; this keeps it formatted. + formatting = + pkgs.runCommand "effectcraft-nix-formatting" { nativeBuildInputs = [ pkgs.nixfmt ]; } + '' + nixfmt --check ${./flake.nix} ${./nix} + touch $out + ''; + } + ); + + formatter = forAllSystems (pkgs: _: pkgs.nixfmt); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..51b636c60 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,200 @@ +# EffectCraft — the desktop app (`effectcraft`) and the CLI/MCP server (`effectcraft-cli`), built from this +# repository: +# +# nix build # → ./result/bin/effectcraft, ./result/bin/effectcraft-cli +# +# Kept in step with the release workflow (.github/workflows/release.yml) and packaging/: +# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump. The +# git sources (storytold/craft-libs, storytold/filmcraft) are fetched at the revision the lock file pins; +# * the desktop file, hicolor icons, MIME types and AppStream metadata are the same files the +# .deb/.rpm ship (packaging/linux/), so `apt` and NixOS users see one identical EffectCraft; +# * `doCheck` is off: the test suite needs corpora and a GPU the Nix sandbox cannot provide +# (`cargo xtask ci` is the gate). `pkgs.effectcraft.overrideAttrs { doCheck = true; }` runs it. +# +# Everything in the product is pure Rust, so this needs no build system beyond cargo plus the +# libraries winit/wgpu `dlopen` at run time (see `runtimeLibs`). +# +# Desktop sessions: the window opens natively on Wayland (GNOME/Mutter, Hyprland and other +# wlroots-style compositors) and falls back to X11/XWayland. File dialogs go through +# xdg-desktop-portal, so the session needs a portal backend: GNOME ships one +# (`xdg-desktop-portal-gnome`); on Hyprland enable +# xdg.portal = { enable = true; extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; }; +# next to `programs.hyprland.enable = true;`. +{ + lib, + stdenv, + rustPlatform, + pkg-config, + makeWrapper, + # Windowing/GPU libraries: needed at build time by the crates that link them, at run time by the + # ones that open them with dlopen() (see `runtimeLibs`), and by the dev shell (flake.nix). + wayland, + wayland-scanner, + libxkbcommon, + libx11, + libxcb, + libxcursor, + libxi, + libxrandr, + vulkan-loader, + libglvnd, + # rfd opens libdbus-1 at run time to reach the xdg-desktop-portal file chooser (Open/Save dialogs). + dbus, + # Audio: cpal links ALSA, which reaches PipeWire/PulseAudio through the ALSA plug-ins. + alsa-lib, + # Validates the desktop file and the AppStream metadata we install (as CI does). + desktop-file-utils, + appstream, + # Adds the driver search path (/run/opengl-driver/lib) to the binaries' RPATH so wgpu finds the + # installed Vulkan/GL driver on NixOS. Optional: harmless on nixpkgs without it. + autoAddDriverRunpath ? null, + # Release date for the AppStream metadata (YYYY-MM-DD); the flake derives it from its own commit. + buildDate ? "1970-01-01", +}: + +let + root = ../.; + + # The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml. + version = (builtins.fromTOML (builtins.readFile (root + "/Cargo.toml"))).workspace.package.version; + + isLinux = stdenv.hostPlatform.isLinux; + + # AppStream id: also the desktop file name, and the icon name in share/icons/hicolor. + appId = "ai.storyteller.effectcraft"; + + # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at + # them. winit loads libwayland-client, libxkbcommon(-x11) and the X11 libraries; wgpu loads the + # Vulkan loader or libEGL (libglvnd); rfd loads libdbus-1 for the file chooser portal. NixOS patches the loader to find the drivers in + # /run/opengl-driver. The wrapper below points LD_LIBRARY_PATH here. + runtimeLibs = lib.optionals isLinux [ + libxkbcommon + wayland + libx11 + libxcb + libxcursor + libxi + libxrandr + vulkan-loader + libglvnd + dbus + ]; + + # The two native binaries. The wasm app (apps/effectcraft-web) is built by `cargo xtask`, not here. + binaries = [ + "effectcraft" + "effectcraft-cli" + ]; + +in +rustPlatform.buildRustPackage { + pname = "effectcraft"; + inherit version; + + src = lib.cleanSourceWith { + src = lib.cleanSource root; + # The Nix files are not part of the build: editing them must not rebuild any Rust. + filter = + path: _type: + !(builtins.elem (baseNameOf path) [ + "flake.nix" + "flake.lock" + "nix" + ]); + }; + + cargoLock = { + lockFile = root + "/Cargo.lock"; + # The git dependencies are pinned by revision in Cargo.lock. + allowBuiltinFetchGit = true; + }; + + # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`). + cargoBuildFlags = [ + "--locked" + "-p" + "effectcraft" + "-p" + "effectcraft-cli" + ]; + + doCheck = false; + + strictDeps = true; + + nativeBuildInputs = [ + pkg-config + ] + ++ lib.optionals isLinux [ + makeWrapper + wayland-scanner + desktop-file-utils + appstream + ] + ++ lib.optionals (isLinux && autoAddDriverRunpath != null) [ autoAddDriverRunpath ]; + + buildInputs = lib.optionals isLinux [ + wayland + libxkbcommon + libx11 + libxcb + libxcursor + libxi + libxrandr + alsa-lib + ]; + + postInstall = '' + # Desktop integration: byte-for-byte the files the .deb/.rpm/.AppImage install. + install -Dm644 packaging/linux/${appId}.desktop $out/share/applications/${appId}.desktop + install -Dm644 packaging/linux/${appId}.mime.xml $out/share/mime/packages/${appId}.xml + cp -r assets/app-icon/hicolor $out/share/icons/ + install -d $out/share/metainfo + substitute packaging/linux/${appId}.metainfo.xml.in \ + $out/share/metainfo/${appId}.metainfo.xml \ + --subst-var-by VERSION ${version} \ + --subst-var-by DATE ${buildDate} + + # Licences for everything embedded in the binaries. + doc=$out/share/doc/effectcraft + for f in README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md assets/ATTRIBUTION.md; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + for f in assets/fonts/OFL-*.txt; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + '' + + lib.optionalString isLinux '' + desktop-file-validate $out/share/applications/${appId}.desktop + XDG_CACHE_HOME=$TMPDIR/.cache appstreamcli validate --no-net \ + $out/share/metainfo/${appId}.metainfo.xml + + # makeWrapper keeps the binaries working when the libs above are only dlopen'd. + for bin in ${lib.concatStringsSep " " (map (b: "$out/bin/" + b) binaries)}; do + wrapProgram "$bin" --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" + done + ''; + + passthru = { + # Shared with the dev shell so `cargo run` inside `nix develop` finds the same dlopen'd libs. + inherit runtimeLibs; + }; + + meta = { + description = "Motion graphics and visual effects application"; + longDescription = '' + EffectCraft is a motion graphics and visual effects application: compositions, keyframes, + effects and rendering. It ships with effectcraft-cli, a headless renderer, command runner and + MCP server for AI agents. + ''; + homepage = "https://getartcraft.com/apps/effectcraft"; + license = with lib.licenses; [ + mit + asl20 + ]; + sourceProvenance = with lib.sourceTypes; [ fromSource ]; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + # `nix run` and the desktop entry both start the GUI. + mainProgram = "effectcraft"; + }; +} diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 3e3b88948..99d8c0444 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,23 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + cs + de + es + fr + pt-BR + ru + uk + ka + ja + zh-Hans + zh-Hant + CFBundleDisplayName EffectCraft CFBundleName diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index afc767de2..0f8072840 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,3 +13,4 @@ serde_json = { workspace = true } chrono = { workspace = true } tempfile = "3" +pkg-config = "0.3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da8026d78..ad03cada7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,6 +407,62 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can +/// check the package names without needing a machine that is actually missing them. +#[cfg(target_os = "linux")] +fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String { + let mut hint = String::from( + "Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ + The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ + pkg-config for `alsa`; without the package the build dies minutes later with an\n\ + unrelated-looking linker error.\n\ + \n\ + Fedora: sudo dnf install alsa-lib-devel\n\ + Debian/Ubuntu: sudo apt install libasound2-dev\n\ + Arch: sudo pacman -S alsa-lib\n\ + \n\ + Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ + PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ + libasound (usually `libasound.so.2`); every CI run will need that variable set.", + ); + if let Some(stderr) = pkg_config_stderr { + hint.push_str("\n\npkg-config said: "); + hint.push_str(stderr); + } + hint +} + +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA +/// pkg-config package before the build dies minutes later at link time. The probe goes through +/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path +/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config` +/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`. +/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately, +/// so a machine with only `pkgconf` does not fail a build that would succeed. +#[cfg(target_os = "linux")] +fn audio_backend() -> Result<(), String> { + // cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would + // only clutter the ci log. + match pkg_config::Config::new().cargo_metadata(false).probe("alsa") { + Ok(_) => Ok(()), + Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr))) + } + Err(e) => Err(format!( + "The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \ + or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \ + dependency performs." + )), + } +} + +#[cfg(not(target_os = "linux"))] +fn audio_backend() -> Result<(), String> { + Ok(()) +} + /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, /// which nextest doesn't run. /// @@ -438,7 +494,8 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 7] = [ + let stages: [(&str, Stage); 8] = [ + ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), @@ -459,6 +516,7 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), + "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -469,7 +527,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -484,3 +542,22 @@ fn main() -> ExitCode { pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::audio_backend_missing_hint; + + #[test] + fn alsa_missing_names_the_package_for_each_distro() { + let e = audio_backend_missing_hint(None); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { + assert!(e.contains(want), "hint lost `{want}`:\n{e}"); + } + } + + #[test] + fn alsa_missing_appends_pkg_config_s_own_detail() { + let e = audio_backend_missing_hint(Some("Package 'alsa' not found")); + assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}"); + } +} diff --git a/xtask/tests/it/audio_probe.rs b/xtask/tests/it/audio_probe.rs new file mode 100644 index 000000000..d984c7637 --- /dev/null +++ b/xtask/tests/it/audio_probe.rs @@ -0,0 +1,126 @@ +//! The Linux audio-backend preflight against fake pkg-config tools. +//! +//! The probe resolves its executable through the pkg-config crate, which reads the environment, +//! so the fake-tool scenarios run the real `xtask audio-backend` binary in a child process with a +//! controlled environment: `Command::env` changes only the child's environment, so the tests need +//! no global env mutation and stay parallel-safe. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A scratch directory holding the fake tools for one scenario: `dir` is the child's `PATH`, +/// `off_path` holds executables reachable only by an explicit `*_PKG_CONFIG` override. +struct FakeTools { + dir: PathBuf, + off_path: PathBuf, +} + +impl FakeTools { + fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!("effectcraft-xtask-audio-{tag}-{}", std::process::id())); + let dir = base.join("bin"); + let off_path = base.join("off-path"); + std::fs::create_dir_all(&dir).expect("create PATH dir"); + std::fs::create_dir_all(&off_path).expect("create off-PATH dir"); + Self { dir, off_path } + } + + /// Write a plain (not executable) file. + fn file(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + let path = if on_path { self.dir.clone() } else { self.off_path.clone() }.join(name); + std::fs::write(&path, body).expect("write fake tool"); + path + } + + /// Write an executable shell script. + fn script(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(on_path, name, body); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod fake tool"); + path + } + + /// A fake `pkgconf` that answers as alsa 1.2.11 linking -lasound. + fn working_pkgconf(&self, on_path: bool) -> PathBuf { + self.script(on_path, "pkgconf", "#!/bin/sh\ncase \" $* \" in\n *\" --modversion \"*) echo 1.2.11 ;;\n *) echo -lasound ;;\nesac\n") + } +} + +impl Drop for FakeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + let _ = std::fs::remove_dir_all(&self.off_path); + } +} + +/// Run the built `xtask audio-backend` with `dir` as the only `PATH`, the given variables set, +/// and every variable the probe reads otherwise cleared, so a developer's own environment +/// cannot leak into the scenario. +fn run_audio_backend(tools: &FakeTools, set: &[(&str, &OsStr)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xtask")) + .arg("audio-backend") + .env("PATH", &tools.dir) + .env_remove("PKG_CONFIG") + .env_remove("PKG_CONFIG_PATH") + .env_remove("PKG_CONFIG_LIBDIR") + .env_remove("PKG_CONFIG_SYSROOT_DIR") + .env_remove("PKG_CONFIG_ALLOW_CROSS") + .env_remove("ALSA_NO_PKG_CONFIG") + .env_remove("HOST_PKG_CONFIG") + .env_remove("TARGET_PKG_CONFIG") + .env_remove("HOST") + .env_remove("TARGET") + .envs(set.iter().copied()) + .output() + .expect("run xtask audio-backend") +} + +#[test] +fn a_host_with_only_pkgconf_still_finds_alsa() { + // The reviewed case: PKG_CONFIG unset, no pkg-config on PATH, only pkgconf. The pkg-config + // crate alsa-sys uses falls back to pkgconf, so the stage must stay green. + let tools = FakeTools::new("pkgconf-only"); + tools.working_pkgconf(true); + let out = run_audio_backend(&tools, &[]); + assert!(out.status.success(), "pkgconf-only host must pass:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn a_probe_that_runs_and_says_no_names_the_package() { + let tools = FakeTools::new("says-no"); + tools.script(true, "pkg-config", "#!/bin/sh\necho 'Package alsa was not found' >&2\nexit 1\n"); + let out = run_audio_backend(&tools, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config"] { + assert!(stderr.contains(want), "hint lost `{want}`:\n{stderr}"); + } + assert!(stderr.contains("not found"), "pkg-config's own detail should be appended:\n{stderr}"); +} + +#[test] +fn a_probe_that_cannot_run_is_not_alsa_missing() { + // PKG_CONFIG names a file that is not an executable; with PKG_CONFIG set the crate does not + // fall back to pkgconf, so the lookup fails to launch — reported separately from "missing". + let tools = FakeTools::new("cannot-run"); + let broken = tools.file(true, "not-an-executable", "this is not a program\n"); + let out = run_audio_backend(&tools, &[("PKG_CONFIG", broken.as_os_str())]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("could not"), "expected the launch-failure error:\n{stderr}"); + assert!(!stderr.contains("alsa-lib-devel"), "must not read as ALSA missing:\n{stderr}"); +} + +#[test] +fn a_host_scoped_override_selects_that_executable() { + // With TARGET and HOST set (build-script style), HOST_PKG_CONFIG wins over the PATH lookup, + // and here it is the only working tool — so success proves the override was used. + let tools = FakeTools::new("scoped"); + let good = tools.working_pkgconf(false); + let out = run_audio_backend( + &tools, + &[("HOST", OsStr::new("x86_64-unknown-linux-gnu")), ("TARGET", OsStr::new("x86_64-unknown-linux-gnu")), ("HOST_PKG_CONFIG", good.as_os_str())], + ); + assert!(out.status.success(), "HOST_PKG_CONFIG override must be honored:\n{}", String::from_utf8_lossy(&out.stderr)); +} diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs new file mode 100644 index 000000000..f2c41c738 --- /dev/null +++ b/xtask/tests/it/main.rs @@ -0,0 +1,4 @@ +//! xtask's integration tests: one binary, modules in `tests/it/`. + +#[cfg(target_os = "linux")] +mod audio_probe;