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/Cargo.lock b/Cargo.lock index 138147e00..04548afbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1653,6 +1653,7 @@ version = "0.18.0" dependencies = [ "effectcraft-geom", "image", + "log", "miniz_oxide 0.9.1", "serde", "serde_json", diff --git a/README.md b/README.md index 81d42360c..c1f895176 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,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 diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index fb00fc90d..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 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())) } @@ -527,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; @@ -543,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/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/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 5080058fb..560a527c7 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1416,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/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/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/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/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/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/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..32e662fec 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; @@ -445,6 +446,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 +1313,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) 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/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/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 b49765c28..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 { @@ -544,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 { @@ -650,6 +630,25 @@ mod tests { 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/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 ddf6a79e5..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,7 +104,7 @@ 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()})) @@ -125,8 +147,16 @@ 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| { @@ -168,6 +198,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { 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/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/edit.rs b/crates/engine/src/cmd/edit.rs index 0130aff70..bfc05933e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -263,6 +263,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 +330,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 +345,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 +354,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..003993fde 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -700,7 +700,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 +736,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 +804,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/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..194845803 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -745,7 +745,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/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/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/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 1cda510e0..079c9bd30 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<()> { @@ -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/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/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/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/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/tests.rs b/crates/engine/src/tests.rs index 579d3d30d..25237493c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -17,6 +17,7 @@ mod history; mod import_project; 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/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 28968b901..64baf14fe 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -803,6 +803,101 @@ fn applying_an_old_preset_upgrades_its_effects_and_property_paths() { 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/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/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..f2849a6be 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] diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 84321ee7e..b36ad77be 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(); 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/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..fd1ace60b 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()))), } diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index ae52d3478..b185c3345 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -39,6 +39,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 +106,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 } @@ -226,10 +280,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 +340,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)) @@ -363,7 +422,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 { @@ -575,6 +634,7 @@ fn premultiplied8(img: &Image) -> Vec { mod tests { use super::*; use rayon::prelude::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; /// 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 +667,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 +827,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/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..0064ba8b7 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -12,5 +12,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/prelude.js b/crates/expr/src/prelude.js index 6611d36aa..c2ca16736 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -670,14 +670,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 +855,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 +1074,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..cf13f02f8 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; 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/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/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/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/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/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/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..1e60e5ab7 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -6,6 +6,7 @@ mod determinism; mod exr_layers; mod footage_reload; mod gif_animates; +mod pick_whip; mod plugins; mod relink; mod tga_import; 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/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 795da99e5..d372ec849 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_raster::AuxChannels; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; +/// The largest display window decoded (16384²; vector footage and Continuously Rasterize +/// stop at the same size). +const MAX_DISPLAY_PIXELS: usize = 1 << 28; + fn is_depth(name: &str) -> 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..306f8d860 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -79,6 +79,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 +115,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..8197cd61f 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() } } 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 fad225040..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; @@ -114,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] { @@ -128,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> { @@ -152,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. @@ -163,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"))); @@ -183,6 +225,7 @@ impl Doc<'_> { return Err(past()); } } + self.charge(total)?; Ok((count, total, Some(Span { buf, base, stride }))) } @@ -216,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, @@ -266,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, @@ -281,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(""); @@ -302,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![]; @@ -569,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")); @@ -589,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, @@ -610,6 +668,7 @@ 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/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/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/item.rs b/crates/project/src/item.rs index 3f53bc823..22759e27d 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; 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..777b7e289 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -509,6 +509,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 +531,7 @@ pub enum OutputFormat { } impl OutputFormat { - pub const ALL: [OutputFormat; 13] = [ + pub const ALL: [OutputFormat; 14] = [ OutputFormat::H264, OutputFormat::Hevc, OutputFormat::Av1, @@ -539,6 +542,7 @@ impl OutputFormat { OutputFormat::JpegSequence, OutputFormat::TiffSequence, OutputFormat::ExrSequence, + OutputFormat::TargaSequence, OutputFormat::Gif, OutputFormat::Wav, OutputFormat::Aiff, @@ -552,6 +556,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 +574,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 +583,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 +612,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 +625,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, @@ -1682,6 +1692,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/render/src/cache.rs b/crates/render/src/cache.rs index 21f745c0e..28d2a8a89 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -497,10 +497,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 +511,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 +520,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. diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 483b19da3..5deffa4db 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -7,7 +7,7 @@ use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, P 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() { @@ -69,7 +69,7 @@ impl Dependencies<'_> { return None; } 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); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index f42d31c2d..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() @@ -903,7 +903,8 @@ 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) } @@ -942,13 +943,15 @@ fn comp_content_key_for_renderer( } } } + // 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 { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..14249900f 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -60,7 +60,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 diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 8366cf7c0..a0c3e7490 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -118,6 +118,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 +153,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/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/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..f04bd4be2 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 { 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/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 30873b6a9..5af8859d5 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -241,6 +241,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. 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/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/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 6f0b710ed..692eef34e 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -326,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() { @@ -339,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]; @@ -354,9 +384,15 @@ 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()) @@ -437,6 +473,119 @@ 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 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]; 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/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/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/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..e1b953d7b 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 { @@ -365,9 +437,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 +455,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/frames.rs b/crates/ui-egui/src/frames.rs index a7023a494..b524b5959 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -31,7 +31,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 +317,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 { @@ -762,12 +745,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 +762,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) @@ -1318,11 +1301,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(); diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 6da1dc269..a2603dfec 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -895,33 +895,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/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 162300f9c..3a98ac40f 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -451,31 +451,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. diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 9af1ec696..207b69a19 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -3353,6 +3353,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. 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 0a4dfdc14..0c334f74d 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -160,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/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 4503acfe9..a3f95ee1c 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() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index fac8f0d53..1ca573e8e 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -149,6 +149,7 @@ pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { /// 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 +160,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 +175,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(), 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/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 30b93e62e..af0cf0e89 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -795,12 +795,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 +1234,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 +1257,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 +1298,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") } @@ -1502,10 +1514,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` @@ -2903,13 +2923,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 { @@ -3388,11 +3405,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); } @@ -4286,6 +4308,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 +4377,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 +4387,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 +4425,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 diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 0355ea5ed..d05787a18 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -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..cfada4728 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1352,8 +1352,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])); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b1538971f..227866bcc 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}; @@ -383,6 +387,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 +460,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 +504,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 +786,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/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..1dcf02da0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,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; 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_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_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_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index b57791997..c8b45e8da 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -237,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..1fb36d90f 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -802,6 +802,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 +1746,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 +1755,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/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/docs/agents.md b/docs/agents.md index ee2a9d1ea..d84442a84 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -614,7 +614,9 @@ 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 @@ -680,6 +682,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..4ffdd9dd7 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. 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/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..fc780016b 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,16 @@ 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. +## 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 9e2249e9b..e10a242fa 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) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), 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/gaps.md b/docs/gaps.md index 3f59b1f99..0dbaa54fa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -63,7 +63,7 @@ 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) | @@ -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 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/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/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/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/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"; + }; +}