diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3630cdca..acbc0103 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -28,9 +28,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 300 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ea726d5d..d9ab3e84 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,5 +1,6 @@ # Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, -# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a +# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall). name: Packaging lint on: @@ -23,7 +24,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update @@ -46,7 +47,7 @@ jobs: } - name: XML, desktop entry, AppStream, Flatpak manifest run: | - xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* + xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml* # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. if grep -oE '-windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. +Installers and executables are code-signed. The installer asks where to install EffectCraft +(Program Files by default), and later versions install into the same folder. To install silently +into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. ### macOS @@ -283,6 +285,20 @@ Installers and executables are code-signed. | Fedora/RHEL/openSUSE | `effectcraft--linux-x86_64.rpm` | `effectcraft--linux-aarch64.rpm` | | | Tarball | `effectcraft--linux-x86_64.tar.gz` | `effectcraft--linux-aarch64.tar.gz` | Unpack anywhere | +**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not +deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use +**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if +XWayland is installed and `DISPLAY` is set: + +```sh +env -u WAYLAND_DISPLAY effectcraft +``` + +When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere +in the window imports it, and a drop-target hint appears while hovering. On native Wayland the +app cannot detect an attempt that generates no event; it shows a startup notice with the +File ▸ Import fallback instead. + ### FreeBSD | Build | File | diff --git a/ROADMAP.md b/ROADMAP.md index 70935025..48d5b56e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # Roadmap +- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. +- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. - 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows. - 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged. - 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged. diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 71ea1ea3..a653b26b 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] //! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] //! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings) +//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE +//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png] //! effectcraft-cli render F.ecproj --queue render the project's Render Queue //! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings //! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half) @@ -39,6 +41,8 @@ use std::io::Write; +mod template; + use effectcraft_automation::tools::{self, Reply}; use effectcraft_automation::{Backend, McpServer}; use serde_json::{Value, json}; @@ -102,6 +106,7 @@ const USAGE: &str = "usage: effectcraft-cli Result<(), Failure> { if args.flag("--autosave") && cmd != "mcp" { return usage_err("--autosave is only supported by mcp"); } + template::validate(cmd, args)?; match cmd { "render" => render(args, json_out)?, "bench" => bench_cmd(args)?, @@ -550,7 +558,13 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(v, saved), json_out); } "render-frame" | "frame" => { - let mut b = backend(args, true)?; + let mut b = if args.opt("--template").is_some() { + let mut s = session(args)?; + template::prepare(&mut s, args)?; + Backend::headless(s) + } else { + backend(args, true)? + }; let comp = args.opt("--comp").and_then(reference); let mut time = args.num("--time")?; let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0); @@ -624,15 +638,19 @@ fn from_cwd(path: &str) -> Result { /// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then /// render the queue with a progress line on stderr. Fails if any item fails. fn render(args: &Args, json_out: bool) -> Result<(), Failure> { - use effectcraft_engine::project::render_queue::RenderStatus; + use effectcraft_engine::project::render_queue::{OutputFormat, RenderStatus}; if args.opt("--bridge").is_some() { return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge"); } let mut s = session(args)?; - match &args.project { - Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, - None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, - }; + if args.opt("--template").is_some() { + template::prepare(&mut s, args)?; + } else { + match &args.project { + Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, + None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, + }; + } let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string()); if !args.flag("--queue") { let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") }; @@ -670,6 +688,11 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { } if let Some(v) = args.num("--bitrate")? { p["bitrate"] = json!(v); + // VP9 WebM encodes by quality unless told to target the bitrate (#440). + // The format parser decides what is WebM, so its aliases (`vp9`, `webmvp9opus`) count. + if p["format"].as_str().and_then(OutputFormat::from_name) == Some(OutputFormat::WebM) { + p["webmBitrate"] = json!(true); + } } for (flag, key) in [ ("--profile", "profile"), diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs new file mode 100644 index 00000000..f4da23cd --- /dev/null +++ b/apps/effectcraft-cli/src/template.rs @@ -0,0 +1,138 @@ +//! CLI-only setup of an Essential Graphics template instance using engine commands. + +use std::io::Read; + +use effectcraft_engine::{ + EngineError, Session, + project::{ItemId, LayerId}, +}; +use serde_json::{Map, Value, json}; + +use crate::{Args, Failure, usage_err}; + +pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> { + if args.opt("--values").is_some() && args.opt("--template").is_none() { + return usage_err("--values requires --template"); + } + if args.opt("--template").is_none() { + return Ok(()); + } + if !matches!(cmd, "render" | "render-frame" | "frame") { + return usage_err("--template is available for render and render-frame"); + } + if args.project.is_some() || args.flag("--demo") || args.opt("--bridge").is_some() || args.flag("--queue") || args.opt("--comp").is_some() { + return usage_err("--template renders a new instance; it cannot be combined with --project, --demo, --bridge, --queue or --comp"); + } + if !args.pos.is_empty() { + return usage_err("template rendering takes --template FILE.ectemplate, without positional arguments"); + } + if cmd == "render" && args.opt("--out").is_none() { + return usage_err("render: --out FILE is required (or --queue)"); + } + // Resolve existing outputs before importing or rendering can overwrite an input. + let out = args.opt("--out").or_else(|| matches!(cmd, "render-frame" | "frame").then_some("frame.png")); + if let Some(out) = out + && let Ok(output) = std::fs::canonicalize(out) + { + for input in ["--template", "--values"] { + if let Some(path) = args.opt(input) + && std::fs::canonicalize(path).is_ok_and(|path| path == output) + { + return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}"))); + } + } + } + Ok(()) +} + +fn values(args: &Args) -> Result, Failure> { + let Some(path) = args.opt("--values") else { return Ok(Map::new()) }; + // A bounded read also handles a file that grows after opening. + const MAX_BYTES: u64 = 1024 * 1024; + let file = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(Failure::Error(format!("values {path}: JSON exceeds 1 MiB"))); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|e| Failure::Error(format!("values {path}: invalid JSON: {e}")))?; + match value { + Value::Object(values) => Ok(values), + _ => Err(Failure::Error(format!("values {path}: expected a JSON object mapping control names to values"))), + } +} + +fn execute(s: &mut Session, command: &str, params: Value) -> Result { + s.execute_checked(command, params).map_err(|e| Failure::Error(e.to_string())) +} + +// Use the same instance commands to reject invalid values before import extracts media. +fn preflight(s: &Session, path: &str, values: &Map) -> Result<(), Failure> { + let bytes = s.services.read_file(path).map_err(|e| Failure::Error(format!("cannot read {path}: {e}")))?; + effectcraft_engine::guard::guarded("template value validation", || { + let bad = |msg: String| EngineError::BadParams { cmd: "essential.importTemplate".into(), msg }; + let (manifest, mut project, _) = effectcraft_engine::commands::essential::read_template(&bytes).map_err(bad)?; + let source = manifest.get("comp").and_then(Value::as_u64).ok_or_else(|| bad("manifest has no `comp`".into()))?; + let base = s.project.next_id; + let source = source.checked_add(base).ok_or_else(|| bad("template composition ID overflows the import offset".into()))?; + effectcraft_engine::project::essential::offset_ids(&mut project, base); + project.fix_next_id(); + let mut check = Session::new(); + check.project = project.into(); + Ok(instantiate(&mut check, source, values)) + }) + .map_err(|e| Failure::Error(e.to_string()))? +} + +pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> { + let Some(path) = args.opt("--template") else { return Ok(()) }; + let values = values(args)?; + if !values.is_empty() { + preflight(s, path, &values)?; + } + let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?; + let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?; + instantiate(s, source, &values) +} + +fn instantiate(s: &mut Session, source: u64, values: &Map) -> Result<(), Failure> { + // Duplicate and empty the source with commands: this preserves exact rational frame rate, + // Tick duration, background, work area and renderer settings without round-tripping floats. + let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?; + let parent = duplicate + .get("items") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_u64) + .ok_or_else(|| Failure::Error("cannot create template render composition".into()))?; + execute(s, "comp.open", json!({"comp": parent}))?; + let layers: Vec = s + .project + .comp(ItemId(parent)) + .ok_or_else(|| Failure::Error("template render composition is missing".into()))? + .layers + .iter() + .map(|layer| layer.id.0) + .collect(); + if !layers.is_empty() { + execute(s, "layer.setSwitch", json!({"comp": parent, "layers": layers, "switch": "lock", "value": false}))?; + execute(s, "edit.clear", json!({"comp": parent, "layers": layers}))?; + } + let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?; + let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?; + // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. + s.edit("Template Duration", None, |project, _| { + let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?; + let duration = comp.duration; + let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?; + layer.out_point = duration; + Ok(()) + }) + .map_err(|e| Failure::Error(e.to_string()))?; + // Let the wrapper carry the source composition's authored motion blur. + execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?; + for (control, value) in values { + execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?; + } + Ok(()) +} diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 723016a8..eb27f931 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value { fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); - d.join(name) + // macOS's `/var` is a symlink to `/private/var`: a child process resolves its working + // directory through `current_dir()`, which is canonical, so compare canonical paths. + std::fs::canonicalize(&d).unwrap_or(d).join(name) } #[test] @@ -373,7 +375,9 @@ fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() { assert_eq!(manifest["ended"], false); let mut restarted = McpChild::start(&config, true); let init = restarted.rpc("initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions carry the auto-save state as JSON, where a Windows path's backslashes + // are escaped. + assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); restarted.tool("open_project", json!({"path":path})); let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"})); @@ -454,3 +458,33 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() { assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); std::fs::remove_dir_all(config).unwrap(); } + +/// `render --bitrate` (#440) targets the bitrate in VP9 WebM. +#[test] +fn render_bitrate_applies_to_vp9_webm() { + let root = tmp("webm-bitrate"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let project = root.join("x.ecproj"); + let p = project.to_str().unwrap(); + ok_json(&[ + "run", + "comp.new", + r##"{"name":"T","width":320,"height":180,"duration":1}"##, + "layer.newSolid", + r##"{"width":320,"height":180}"##, + "effect.apply", + r##"{"layer":"#1","effect":"ec.noise.fractal"}"##, + "--empty", + "--save-as", + p, + ]); + let size = |kbps: &str| { + let out = root.join(format!("b{kbps}.webm")); + ok_json(&["render", "--project", p, "--start", "0", "--end", "0.5", "--bitrate", kbps, "--out", out.to_str().unwrap()]); + std::fs::metadata(&out).unwrap().len() + }; + let (low, high) = (size("100"), size("8000")); + assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index e6525561..ac67280e 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -1,6 +1,7 @@ //! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an //! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on -//! an instance, rendered, exported as a template and imported into a fresh project. +//! an instance, rendered, exported as a template and imported into a fresh project. Direct +//! template rendering uses the same original, procedural fixture under the project licence. use std::path::PathBuf; use std::process::Command; @@ -111,5 +112,543 @@ fn essential_graphics_template_via_script_and_commands() { assert!(imp["saved"].is_string(), "{imp}"); let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]); assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}"); + + // One-shot rendering applies the JSON values to an instance, preserving the template defaults. + let original_template = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "direct.png"]); + assert_eq!(image::open(dir.join("direct.png")).unwrap().to_rgba8(), edit); + ok(&dir, &["frame", "--template", "lt.ectemplate", "--frame", "12", "--out", "defaults.png"]); + assert_eq!(image::open(dir.join("defaults.png")).unwrap().to_rgba8(), plain); + + let output = dir.join("sequence/direct_[#####].png"); + let rendered = ok( + &dir, + &[ + "render", + "--template", + "lt.ectemplate", + "--values", + "values.json", + "--out", + output.to_str().unwrap(), + "--format", + "png", + "--channels", + "rgb", + "--start", + "1", + "--end", + "1.0833333333333333", + ], + ); + assert_eq!(rendered["rendered"].as_array().unwrap().len(), 1, "{rendered}"); + let frames: Vec<_> = std::fs::read_dir(dir.join("sequence")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "png")) + .collect(); + assert_eq!(frames.len(), 1, "{frames:?}"); + assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + + // Invalid values fail before the queue creates an output directory, or frame rendering + // overwrites an existing output. A JSON object and existing control names are required. + for (name, values, expected) in [ + ("malformed", "{", "invalid JSON"), + ("array", "[]", "JSON object"), + ("unknown", r#"{"Subtitle":"x"}"#, "controls: Title, Bar Color, Bar Opacity"), + ("invalid-value", r#"{"Bar Opacity":"bad"}"#, "can't use"), + ] { + let values_file = format!("{name}.json"); + std::fs::write(dir.join(&values_file), values).unwrap(); + let output_dir = dir.join(format!("failed-{name}")); + let out = output_dir.join("frame_[#####].png"); + let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); + assert_eq!(code, 1, "{name}: {error}"); + assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}"); + assert!(!output_dir.exists(), "{name}: invalid input created render output"); + std::fs::write(dir.join("untouched.png"), b"existing output").unwrap(); + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); + assert_eq!(code, 1, "{name}: {error}"); + assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output"); + } + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); + assert_eq!(code, 1, "{error}"); + assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}"); + assert!(!dir.join("missing.png").exists()); + std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap(); + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); + assert_eq!(code, 1, "{error}"); + assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}"); + assert!(!dir.join("large.png").exists()); + + for args in [ + vec!["render-frame", "--values", "values.json"], + vec!["info", "--template", "lt.ectemplate"], + vec!["render-frame", "--template", "lt.ectemplate", "--comp", "Lower Third"], + vec!["render-frame", "t.ecproj", "--template", "lt.ectemplate"], + vec!["render", "--template", "lt.ectemplate", "--queue"], + vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); + } + + // File-loaded templates can use exact rational rates that comp.new's float parser rounds. + // Preserve that rate, Tick duration, work area, background and locked source layers. + let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Lower Third").unwrap().id; + let rate = effectcraft_time::FrameRate::new(24001, 1001); + let comp = project.comp_mut(source).unwrap(); + comp.frame_rate = rate; + comp.duration = rate.tick_of(24); + comp.work_area = (rate.tick_of(3), rate.tick_of(7)); + comp.background = [0.08, 0.16, 0.24]; + comp.layers.first_mut().unwrap().switches.locked = true; + std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap(); + ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); + let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap(); + let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); + assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}"); + assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90)); + let output = dir.join("rational-work-area/frame_[#####].png"); + ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); + let count = std::fs::read_dir(dir.join("rational-work-area")) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.path().extension().is_some_and(|extension| extension == "png")) + .count(); + assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6"); + assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + let _ = std::fs::remove_dir_all(&dir); +} +fn template_output_alias_fixture(case: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_before = std::fs::read(dir.join("values.json")).unwrap(); + + // The same valid inputs must still render to a distinct output before testing aliases. + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let image = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = image.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), values_before); + dir +} + +fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_before = std::fs::read(dir.join("values.json")).unwrap(); + // Read the inputs before interpreting the subprocess status or error text. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(dir) + .args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_after = std::fs::read(dir.join("values.json")).unwrap(); + let template_preserved = template_after == template_before; + let values_preserved = values_after == values_before; + eprintln!( + "output={out:?} status={:?} template_preserved={template_preserved} values_preserved={values_preserved} template_bytes={}->{} values_bytes={}->{} stdout={} stderr={}", + result.status.code(), + template_before.len(), + template_after.len(), + values_before.len(), + values_after.len(), + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); + assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file"); + assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file"); + assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input"); +} + +#[test] +fn template_frame_output_cannot_replace_its_template_input() { + let dir = template_output_alias_fixture("template"); + assert_template_output_preserves_inputs(&dir, "lt.ectemplate"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_frame_output_cannot_replace_its_values_input() { + let dir = template_output_alias_fixture("values"); + assert_template_output_preserves_inputs(&dir, "values.json"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_frame_output_cannot_alias_its_template_through_parent_components() { + let dir = template_output_alias_fixture("normalized-template"); + std::fs::create_dir(dir.join("alias")).unwrap(); + let out = "alias/../lt.ectemplate"; + assert_eq!(dir.join(out).canonicalize().unwrap(), dir.join("lt.ectemplate").canonicalize().unwrap()); + assert_template_output_preserves_inputs(&dir, out); + let _ = std::fs::remove_dir_all(&dir); +} + +fn timeline_tail_render_pair(dir: &PathBuf, name: &str, duration: effectcraft_time::Tick) { + let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("base.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Lower Third").unwrap().id; + let comp = project.comp_mut(source).unwrap(); + assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(12, 1)); + comp.duration = duration; + comp.work_area = (effectcraft_time::Tick::ZERO, duration); + for layer in &mut comp.layers { + layer.out_point = duration; + } + assert!(comp.layers.iter().all(|layer| layer.is_active_at(effectcraft_time::Tick::from_seconds_f64(1.0)))); + let project_name = format!("{name}.ecproj"); + let template_name = format!("{name}.ectemplate"); + let source_frame = format!("{name}-source.png"); + let template_frame = format!("{name}-template.png"); + std::fs::write(dir.join(&project_name), project.to_json()).unwrap(); + let params = serde_json::json!({"comp":"Lower Third", "path":template_name}).to_string(); + let exported = ok(dir, &["exec", "essential.exportTemplate", ¶ms, &project_name]); + assert_eq!(exported["controls"], 3, "{exported}"); + let project_before = std::fs::read(dir.join(&project_name)).unwrap(); + let template_before = std::fs::read(dir.join(&template_name)).unwrap(); + + let direct = ok(dir, &["render-frame", &project_name, "--comp", "Lower Third", "--time", "1", "--out", &source_frame]); + let instance = ok(dir, &["render-frame", "--template", &template_name, "--time", "1", "--out", &template_frame]); + for result in [&direct, &instance] { + assert_eq!(result["time"].as_f64(), Some(1.0), "{name}: {result}"); + assert_eq!((result["width"].as_u64(), result["height"].as_u64()), (Some(320), Some(90)), "{name}: {result}"); + } + assert_eq!(std::fs::read(dir.join(&project_name)).unwrap(), project_before, "{name}: rendering preserves the source project bytes"); + assert_eq!(std::fs::read(dir.join(&template_name)).unwrap(), template_before, "{name}: rendering preserves the exported template bytes"); + let direct = image::open(dir.join(&source_frame)).unwrap().to_rgba8(); + let instance = image::open(dir.join(&template_frame)).unwrap().to_rgba8(); + assert_eq!(direct.dimensions(), (320, 90)); + assert_eq!(instance.dimensions(), direct.dimensions()); + let direct_bar = direct.get_pixel(10, 75).0; + let instance_bar = instance.get_pixel(10, 75).0; + assert!( + direct_bar[0] > 240 && direct_bar[1] < 15 && direct_bar[2] < 15, + "{name}: direct source has the authored red bar at the requested valid time: {direct_bar:?}" + ); + let different = direct.pixels().zip(instance.pixels()).filter(|(a, b)| a != b).count(); + eprintln!("{name}: duration={duration:?}, direct_bar={direct_bar:?}, instance_bar={instance_bar:?}, different_pixels={different}"); + assert_eq!(different, 0, "{name}: the template instance must preserve every source pixel at the same valid time"); +} + +#[test] +fn template_instance_preserves_the_partial_final_source_frame() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-partial-tail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "base.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Establish ordinary instance rendering before testing the final partial source frame. + timeline_tail_render_pair(&dir, "aligned", effectcraft_time::Tick::from_seconds_f64(2.0)); + let partial = effectcraft_time::Tick(effectcraft_time::TICKS_PER_SECOND * 101 / 100); + assert_eq!(partial.seconds(), 1.01); + timeline_tail_render_pair(&dir, "partial", partial); + let _ = std::fs::remove_dir_all(&dir); +} + +// Original procedural motion: the renderer supplies the expected pixels for both states. +const GEOMETRY_BLUR_BUILD: &str = r#" +app.beginUndoGroup("Build Blur Template"); +var comp = app.project.items.addComp("Blur Template", 160, 64, 1, 2, 24); +var mover = comp.layers.addSolid([1, 0.3, 0.1], "Mover", 12, 16, 1, 2); +var position = mover.property("ADBE Transform Group").property("ADBE Position"); +position.setValueAtTime(0, [16, 32]); +position.setValueAtTime(1, [144, 32]); +position.setInterpolationTypeAtKey(1, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR); +position.setInterpolationTypeAtKey(2, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR); +mover.motionBlur = true; +comp.motionBlur = true; +comp.shutterAngle = 180; +comp.shutterPhase = -90; +mover.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity"); +app.endUndoGroup(); +"#; + +fn geometry_blur_difference(a: &image::RgbaImage, b: &image::RgbaImage) -> usize { + assert_eq!(a.dimensions(), b.dimensions()); + a.pixels().zip(b.pixels()).filter(|(a, b)| a != b).count() +} + +fn geometry_blur_assert_inputs(dir: &std::path::Path, inputs: &[(&str, Vec)]) { + for (name, before) in inputs { + assert_eq!(&std::fs::read(dir.join(name)).unwrap(), before, "template rendering preserves input {name}"); + } +} + +#[test] +fn template_wrapper_preserves_authored_motion_blur() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-authored-blur-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("motion.jsx"), GEOMETRY_BLUR_BUILD).unwrap(); + let built = ok(&dir, &["script", "motion.jsx", "--save-as", "moving.ecproj"]); + assert_eq!(built["ok"], true, "{built}"); + + // Check the authored switch/key setup, then use an unchanged top-level composition as + // the production pixel oracle. No expected raster or threshold is synthesized here. + let project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("moving.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Blur Template").unwrap().id; + let comp = project.comp(source).unwrap(); + assert!(comp.enable_motion_blur); + assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(24, 1)); + assert_eq!(comp.layers.len(), 1); + assert!(comp.layers[0].switches.motion_blur); + assert_eq!(comp.layers[0].props.prop("transform/position").unwrap().keys.len(), 2); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"moving.ectemplate"}"#, "moving.ecproj"]); + assert_eq!(exported["controls"], 1, "{exported}"); + + // The positive control disables only the source layer's authored Motion Blur switch. + // It retains the same geometry, timing, source comp settings and template-control values. + ok( + &dir, + &[ + "run", + "moving.ecproj", + "layer.setSwitch", + r#"{"comp":"Blur Template","layers":["Mover"],"switch":"motionBlur","value":false}"#, + "--save-as", + "unblurred.ecproj", + ], + ); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"unblurred.ectemplate"}"#, "unblurred.ecproj"]); + assert_eq!(exported["controls"], 1, "{exported}"); + let inputs: Vec<_> = ["moving.ecproj", "moving.ectemplate", "unblurred.ecproj", "unblurred.ectemplate"] + .into_iter() + .map(|name| (name, std::fs::read(dir.join(name)).unwrap())) + .collect(); + + ok(&dir, &["render-frame", "moving.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-blur.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + ok(&dir, &["render-frame", "unblurred.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-unblurred.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + ok(&dir, &["render-frame", "--template", "unblurred.ectemplate", "--time", "0.5", "--transparent", "--out", "template-unblurred.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + let blurred = image::open(dir.join("direct-blur.png")).unwrap().to_rgba8(); + let unblurred = image::open(dir.join("direct-unblurred.png")).unwrap().to_rgba8(); + let template_unblurred = image::open(dir.join("template-unblurred.png")).unwrap().to_rgba8(); + assert_eq!(blurred.dimensions(), (160, 64)); + assert_eq!(unblurred.dimensions(), (160, 64)); + assert!(blurred.pixels().any(|p| p.0[3] > 0), "the direct blurred source is visible"); + assert!(unblurred.pixels().any(|p| p.0[3] > 0), "the direct unblurred source is visible"); + let authored_blur_difference = geometry_blur_difference(&blurred, &unblurred); + assert!(authored_blur_difference > 0, "the real source renderer must show the authored motion blur"); + assert_eq!(template_unblurred, unblurred, "an unblurred template preserves its source pixels"); + eprintln!("CLI_TEMPLATE_BLUR_CONTROLS_PASSED source_blur_vs_source_unblurred={authored_blur_difference}"); + + ok(&dir, &["render-frame", "--template", "moving.ectemplate", "--time", "0.5", "--transparent", "--out", "template-blur.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + let template_blurred = image::open(dir.join("template-blur.png")).unwrap().to_rgba8(); + let differing_pixels = geometry_blur_difference(&template_blurred, &blurred); + let unblurred_difference = geometry_blur_difference(&template_blurred, &unblurred); + eprintln!( + "template authored-blur result: source_vs_template_differing_pixels={differing_pixels}, template_vs_unblurred_differing_pixels={unblurred_difference}" + ); + assert_eq!(differing_pixels, 0, "template wrapper must preserve authored source motion blur"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_unknown_control_does_not_replace_extracted_media() { + let temp_root = std::env::temp_dir().canonicalize().unwrap(); + let dir = temp_root.join(format!("ec-qa-template-embedded-media-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Both PNGs are generated here; the template uses an ordinary file-backed footage layer. + image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap(); + let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]); + let imported = &imported_reply["result"]; + assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}"); + let items = imported["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{imported}"); + let item = items[0].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string(); + ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]); + let media = info["media"].as_array().unwrap(); + assert_eq!(media.len(), 1, "{info}"); + let files = media[0]["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{info}"); + let entry = files[0].as_str().unwrap(); + let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap()); + assert_eq!(relative.components().count(), 2, "{entry}"); + assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}"); + assert_eq!(relative.file_name().unwrap(), "source.png"); + let extracted = dir.join("lt Media").join(relative); + assert!(extracted.starts_with(&dir)); + + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_before = std::fs::read(dir.join("values.json")).unwrap(); + let source_before = std::fs::read(dir.join("source.png")).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = ordinary.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}"); + let footage = ordinary.get_pixel(8, 8).0; + assert!( + (footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255, + "embedded footage rendered: {footage:?}" + ); + assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before); + assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before); + let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap(); + + // Keep a different valid PNG at the exact extraction path and an existing render output. + image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap(); + let sentinel_before = std::fs::read(&extracted).unwrap(); + assert_ne!(sentinel_before, source_before); + std::fs::copy(dir.join("ordinary.png"), dir.join("failed.png")).unwrap(); + let output_before = std::fs::read(dir.join("failed.png")).unwrap(); + std::fs::write(dir.join("bad-values.json"), r#"{"Subtitle":"x"}"#).unwrap(); + let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap(); + + // Capture every file before interpreting status/JSON, so a failed command cannot hide a write. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(&dir) + .args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_after = std::fs::read(dir.join("values.json")).unwrap(); + let invalid_values_after = std::fs::read(dir.join("bad-values.json")).unwrap(); + let source_after = std::fs::read(dir.join("source.png")).unwrap(); + let ordinary_after = std::fs::read(dir.join("ordinary.png")).unwrap(); + let output_after = std::fs::read(dir.join("failed.png")).unwrap(); + let sentinel_after = std::fs::read(&extracted).unwrap(); + let media_preserved = sentinel_after == sentinel_before; + eprintln!( + "EMBEDDED_MEDIA_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={}", + result.status.code(), + source_after == source_before, + output_after == output_before, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(result.status.code(), Some(1)); + let reply: Value = serde_json::from_slice(&result.stdout).unwrap(); + let error = reply["error"].as_str().unwrap(); + assert!(error.contains("no control named `Subtitle`") && error.contains("controls: Title, Bar Color, Bar Opacity"), "{reply}"); + assert_eq!(template_after, template_before); + assert_eq!(valid_values_after, valid_values_before); + assert_eq!(invalid_values_after, invalid_values_before); + assert_eq!(source_after, source_before); + assert_eq!(ordinary_after, ordinary_before); + assert_eq!(output_after, output_before); + eprintln!("EMBEDDED_MEDIA_UNKNOWN_CONTROL: valid ordinary render and actual unknown-control failure verified"); + assert!(media_preserved, "invalid template values must fail before overwriting previously extracted media"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_missing_output_does_not_replace_extracted_media() { + let temp_root = std::env::temp_dir().canonicalize().unwrap(); + let dir = temp_root.join(format!("ec-qa-template-missing-output-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Both PNGs are generated here; the template uses an ordinary file-backed footage layer. + image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap(); + let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]); + let imported = &imported_reply["result"]; + assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}"); + let items = imported["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{imported}"); + let item = items[0].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string(); + ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]); + let media = info["media"].as_array().unwrap(); + assert_eq!(media.len(), 1, "{info}"); + let files = media[0]["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{info}"); + let entry = files[0].as_str().unwrap(); + let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap()); + assert_eq!(relative.components().count(), 2, "{entry}"); + assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}"); + assert_eq!(relative.file_name().unwrap(), "source.png"); + let extracted = dir.join("lt Media").join(relative); + assert!(extracted.starts_with(&dir)); + + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_before = std::fs::read(dir.join("values.json")).unwrap(); + let source_before = std::fs::read(dir.join("source.png")).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = ordinary.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}"); + let footage = ordinary.get_pixel(8, 8).0; + assert!( + (footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255, + "embedded footage rendered: {footage:?}" + ); + assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before); + assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before); + let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap(); + + // Keep a different valid PNG at the normal extraction path before a usage error. + image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap(); + let sentinel_before = std::fs::read(&extracted).unwrap(); + assert_ne!(sentinel_before, source_before); + + // Observe writes before interpreting the actual CLI usage exit. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(&dir) + .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_after = std::fs::read(dir.join("values.json")).unwrap(); + let source_after = std::fs::read(dir.join("source.png")).unwrap(); + let output_after = std::fs::read(dir.join("ordinary.png")).unwrap(); + let sentinel_after = std::fs::read(&extracted).unwrap(); + let media_preserved = sentinel_after == sentinel_before; + let stderr = String::from_utf8_lossy(&result.stderr); + eprintln!( + "MISSING_OUTPUT_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={stderr}", + result.status.code(), + source_after == source_before, + output_after == ordinary_before, + String::from_utf8_lossy(&result.stdout), + ); + assert_eq!(result.status.code(), Some(2), "{stderr}"); + assert!(stderr.contains("render: --out FILE is required (or --queue)"), "{stderr}"); + assert_eq!(template_after, template_before); + assert_eq!(values_after, valid_values_before); + assert_eq!(source_after, source_before); + assert_eq!(output_after, ordinary_before); + assert!(!dir.join("frame.png").exists(), "a missing render output must not create a default frame"); + eprintln!("MISSING_OUTPUT_REQUIRED_OUT: valid ordinary render/extraction and actual required-output usage failure verified"); + assert!(media_preserved, "missing --out must fail before overwriting previously extracted media"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5ca52d69..ad3fc8b5 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -21,6 +21,11 @@ rfd = { workspace = true } cpal = { workspace = true } log = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in +# Cargo.lock (accesskit, through eframe), so it adds no new crates. +zbus = "5.19" + [build-dependencies] # Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" @@ -35,3 +40,8 @@ muda = { version = "0.21", default-features = false } objc2 = "0.6" # NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields. objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] } +# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by +# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our +# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. +fmv-macos-events = "=0.1.0" diff --git a/apps/effectcraft/src/appearance.rs b/apps/effectcraft/src/appearance.rs new file mode 100644 index 00000000..0c625646 --- /dev/null +++ b/apps/effectcraft/src/appearance.rs @@ -0,0 +1,154 @@ +//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸ +//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG +//! desktop portal instead. Other platforms use what egui reports. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The colour-scheme number inside a portal reply. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + // `Settings.Read` answers with a variant, and some portals nest another variant inside it. + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// Fallback for desktops without the settings portal. It runs once at start-up, by absolute path, +/// and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no +/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before reading so a change during startup stays queued. + let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + // Startup can finish after the UI's bounded wait: wake its first frame too. + if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial) + && let Some(ctx) = worker_wake.get() + { + ctx.request_repaint(); + } + let _ = ready.send(()); + let Some(signals) = signals else { return }; + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + if worker_value.swap(code, Ordering::Relaxed) != code + && let Some(ctx) = worker_wake.get() + { + ctx.request_repaint(); + } + } + }) + .is_err() + { + return None; + } + // The first frame already has the answer when the portal replies quickly. + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn portal_and_gsettings_values_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1)); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::code_of(super::decode(2)), 2); + } +} diff --git a/apps/effectcraft/src/apple_events.rs b/apps/effectcraft/src/apple_events.rs new file mode 100644 index 00000000..b590c7ac --- /dev/null +++ b/apps/effectcraft/src/apple_events.rs @@ -0,0 +1,80 @@ +//! macOS open-documents and quit Apple events. +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` don't +//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so the project never opened. Handling it ourselves needs Objective-C +//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events` +//! crate (also used by PhotoCraft and PdfCraft) wraps exactly that, an `NSAppleEventManager` +//! handler registered before Finder's launch event that leaves winit's delegate alone, behind a +//! safe main-thread API. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use fmv_macos_events::{Event, Inbox, Registration}; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`Opened::feed`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Opened { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + Opened { inbox: self.inbox.clone(), pending: Vec::new() } + } +} + +/// Documents macOS asked us to open, waiting for the UI to take them. +pub struct Opened { + inbox: Inbox, + /// Paths that arrived before the UI was ready (the Finder event that launched the app comes + /// before the first frame is drawn). + pending: Vec, +} + +impl Opened { + /// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens, + /// asking about unsaved changes first; media is imported), and act on quit requests. + pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) { + for e in self.inbox.drain() { + match e { + Event::Open(paths) => self.pending.extend(paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } + if self.pending.is_empty() { + return; + } + if !ready { + ctx.request_repaint(); + return; + } + raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle)); + } +} + +/// A document macOS asked us to open, presented to egui as a dropped file. +#[derive(Debug)] +struct OpenedFile(PathBuf); + +impl egui::DroppedFile for OpenedFile { + fn path(&self) -> &Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..a7b24f3e 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -11,8 +11,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] // Built everywhere so its tests run on every platform; only Linux AppImages use it. +mod appearance; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod appimage; +#[cfg(target_os = "macos")] +mod apple_events; mod audio_out; mod control_server; mod launch_guard; @@ -36,6 +39,22 @@ fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(png).unwrap_or_default() } +/// Both native JSON projects and Save a Copy As XML projects are openable on launch. +/// Keep this classification shared by project opening and the subsequent media-import pass. +fn is_project_file(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx")) +} + +/// winit 0.30 cannot deliver native Wayland file drops. XWayland can. +#[cfg(target_os = "linux")] +fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> { + (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11"))) + .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).") +} + /// The command line. #[derive(Debug, Default, PartialEq)] struct Args { @@ -101,6 +120,12 @@ fn main() -> eframe::Result { // Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never // appears shows how far start-up got (#234). log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION")); + // Registered before the event loop starts, so it catches the Finder event that launched us as + // well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; let result = eframe::run_native( "EffectCraft", options, @@ -143,7 +168,7 @@ fn main() -> eframe::Result { } session.load_settings(); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; - let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned(); + let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { if let Err(e) = session.execute("file.open", json!({"path": p})) { eprintln!("effectcraft: {e}"); @@ -151,7 +176,7 @@ fn main() -> eframe::Result { } else if demo { let _ = session.execute("file.openDemoProject", json!({})); } - let media: Vec = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect(); + let media: Vec = files.iter().filter(|f| !is_project_file(f)).cloned().collect(); if !media.is_empty() && let Err(e) = session.execute("file.import", json!({"paths": media})) { @@ -170,6 +195,12 @@ fn main() -> eframe::Result { if let Some(r) = recovery { app.offer_recovery(r); } + #[cfg(target_os = "linux")] + if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref()) + && app.ui.status.is_empty() + { + app.ui.status = note.into(); + } app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect() })); @@ -179,6 +210,7 @@ fn main() -> eframe::Result { app.hooks.pick_open_project = Some(Box::new(|| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) })); + app.hooks.system_theme = appearance::service(); app.hooks.audio_device = Some(Box::new(audio_out::open)); app.hooks.audio_devices = Some(Box::new(audio_out::devices)); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); @@ -207,6 +239,8 @@ fn main() -> eframe::Result { Ok(Box::new(Desktop { #[cfg(target_os = "macos")] menu: native_menu::NativeBar::new(&cc.egui_ctx), + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&cc.egui_ctx), app, launch, drawn: false, @@ -274,6 +308,9 @@ struct Desktop { app: EffectcraftApp, #[cfg(target_os = "macos")] menu: native_menu::NativeBar, + /// Documents opened from Finder, the Dock or `open` (macOS). + #[cfg(target_os = "macos")] + apple_events: apple_events::Opened, launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, @@ -300,6 +337,8 @@ impl eframe::App for Desktop { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + #[cfg(target_os = "macos")] + self.apple_events.feed(self.app.is_ready(), ctx, raw_input); self.app.raw_input_hook(ctx, raw_input); } } @@ -340,6 +379,17 @@ mod tests { parse_args(args.iter().map(|a| a.to_string()), None) } + #[cfg(target_os = "linux")] + #[test] + fn wayland_drop_notice_recommends_import_or_xwayland() { + use std::ffi::OsStr; + let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap(); + assert!(warning.contains("File ▸ Import") && warning.contains("XWayland")); + assert!(super::wayland_drop_notice(None, None).is_none()); + assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none()); + assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none()); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { @@ -353,6 +403,20 @@ mod tests { assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384); } + /// A positional .ecprojx is a project file, not an unsupported media import (#528). + #[test] + fn launch_classifies_xml_and_json_projects_as_projects() { + for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] { + assert!(super::is_project_file(path), "{path}"); + } + for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] { + assert!(!super::is_project_file(path), "{path}"); + } + let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()]; + assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx")); + assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::>(), vec!["clip.mov"],); + } + /// Launching without a project opens an empty project, not the demo (#204). #[test] fn no_arguments_start_an_empty_project() { diff --git a/contributors/people.toml b/contributors/people.toml index 441b42d7..72e247c8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -36,3 +36,16 @@ display_name = "ShirokoWaWu" [people.sanyamsingh001] display_name = "sanyamsingh001" sync_github_name = false + +[people.Aavirash] +display_name = "Asquareportal" + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "tylerjenningsw" +sync_github_name = false + +[people.llopjulen] +real_name = "Julen Llop" +display_name = "Julen Llop" +sync_github_name = false diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index ba8ba034..99b7ac61 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -102,28 +102,30 @@ impl McpServer { } /// Serve until EOF on `input`. Input is read on its own thread so a long render can report - /// progress, be cancelled and let other requests through while it runs (`long_job`). - pub fn serve(&mut self, input: impl BufRead + Send, mut output: impl Write) -> std::io::Result<()> { + /// progress, be cancelled and let other requests through while it runs (`long_job`). The + /// reader thread is detached: when writing a reply fails, the server returns at once instead + /// of waiting for a read that may never complete. + pub fn serve(&mut self, input: impl BufRead + Send + 'static, mut output: impl Write) -> std::io::Result<()> { let (tx, inbox) = std::sync::mpsc::channel::>(); // A read error ends the input like EOF and is returned once the queued requests are answered. - let read_error = std::sync::Mutex::new(None::); - let served = std::thread::scope(|scope| { - let read_error = &read_error; - std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || { - for line in input.lines() { - let line = match line { - Ok(line) => line, - Err(e) => { - *read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e); - break; - } - }; - if tx.send(Some(line)).is_err() { - return; + let read_error = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let reader_error = read_error.clone(); + std::thread::Builder::new().name("mcp-input".into()).spawn(move || { + for line in input.lines() { + let line = match line { + Ok(line) => line, + Err(e) => { + *reader_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e); + break; } + }; + if tx.send(Some(line)).is_err() { + return; } - let _ = tx.send(None); - })?; + } + let _ = tx.send(None); + })?; + let served = (|| { while let Ok(Some(line)) = inbox.recv() { let long = serde_json::from_str::(line.trim()).ok().and_then(|m| crate::long_job::long_call(self, &m)); if let Some(call) = long { @@ -142,7 +144,7 @@ impl McpServer { Some(e) => Err(e), None => Ok(()), } - }); + })(); match (served, self.checkpoint(true)) { (r, Ok(())) => r, (Ok(()), Err(e)) => Err(e), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 44a61cc1..51d15694 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -35,6 +35,28 @@ fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value { serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap() } +#[test] +fn property_group_error_points_to_layer_inspection() { + let mut s = server(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main"}})); + let l = call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"text": "Hi"}}))["layer"].clone(); + call_json(&mut s, "execute_command", json!({"command": "comp.renderer", "params": {"renderer": "advanced3d"}})); + for tool in ["get_property", "set_property", "add_keyframe"] { + let mut args = json!({"layer": l, "path": "geometryOptions", "time": 0}); + if tool != "get_property" { + args["value"] = json!(0); + } + let (content, err) = call(&mut s, tool, args); + assert!(err, "{tool}: {content:?}"); + let text = content[0]["text"].as_str().unwrap(); + assert!(text.contains("is a property group"), "{tool}: {text}"); + assert!(text.contains("get_layer"), "{tool}: {text}"); + } + let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true})); + assert!(tree["properties"].as_array().unwrap().iter().any(|p| p["path"] == "geometryOptions/extrusionDepth")); + assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number()); +} + #[test] fn initialize_and_list_tools() { let mut s = server(); @@ -135,6 +157,21 @@ fn headless_workflow() { let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0})); assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}"); + // Comp-time keys on an offset layer (#257): the key lands where the Timeline shows it. + call_json(&mut s, "execute_command", json!({"command": "layer.timing", "params": {"layer": l, "start": 1.0}})); + let p = call_json( + &mut s, + "add_keyframe", + json!({"layer": l, "path": "transform/scale", "keys": [{"time": 1.5, "value": [50, 50]}, {"time": 2.5, "value": [100, 100]}], "timeBase": "comp", "interpolation": "hold"}), + ); + let times: Vec = p["keys"].as_array().unwrap().iter().map(|k| k["time"].as_f64().unwrap()).collect(); + // Comp times snap to the comp's frames (29.97 fps) before becoming layer time. + assert!((times[0] - 0.5).abs() < 0.02 && (times[1] - 1.5).abs() < 0.02, "layer-time keys: {times:?}"); + assert_eq!(p["keys"][0]["out"], "Hold", "the comp-time keys were selected for the interpolation"); + call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/scale", "value": [75, 75], "time": 3.0, "timeBase": "comp"})); + let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/scale"})); + assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 0.02), "{p}"); + // Expression via set_property. let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"})); assert_eq!(p["expression"], "time * 90"); @@ -211,7 +248,7 @@ fn stdio_loop() { .map(|v| v.to_string() + "\n") .collect::(); let mut out = Vec::new(); - s.serve(input.as_bytes(), &mut out).unwrap(); + s.serve(std::io::Cursor::new(input), &mut out).unwrap(); let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len(), 2); assert_eq!(lines[1]["id"], 2); @@ -229,7 +266,7 @@ fn headless_autosave_on_eof_preserves_unsaved_project() { json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "layer.newText", "params": {"text": "Unsaved title"}}}}), ].iter().map(|v| v.to_string() + "\n").collect::(); let mut output = Vec::new(); - s.serve(input.as_bytes(), &mut output).unwrap(); + s.serve(std::io::Cursor::new(input), &mut output).unwrap(); let path = s.backend().session().unwrap().autosave.last_path.clone(); assert!(path.is_some(), "disconnect discarded the dirty project without an auto-save"); let path = path.unwrap(); @@ -269,7 +306,9 @@ fn autosave_is_durable_before_reply_and_queries_do_not_rotate() { drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists. let mut restarted = server().with_autosave(&dir).unwrap(); let init = rpc(&mut restarted, 1, "initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions carry the auto-save state as JSON, where a Windows path's backslashes + // are escaped. + assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); call_json(&mut restarted, "open_project", json!({"path": path})); assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable"); @@ -351,6 +390,30 @@ fn autosave_marks_saved_or_undone_work_clean_without_losing_its_path() { std::fs::remove_dir_all(dir).unwrap(); } +/// A reply that cannot be written ends `serve` with the write error, even while stdin stays open +/// (the client is gone but never closed its end): the reader thread must not be waited for. +#[test] +fn broken_output_returns_while_stdin_stays_open() { + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let (in_r, mut in_w) = std::io::pipe().unwrap(); + writeln!(in_w, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap(); + let (done_tx, done) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = done_tx.send(server().serve(BufReader::new(in_r), Broken)); + }); + let result = done.recv_timeout(std::time::Duration::from_secs(10)).expect("serve must return while stdin is still open"); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe); + drop(in_w); +} + #[test] fn autosave_flushes_on_read_errors_and_broken_output() { struct Broken; @@ -897,6 +960,61 @@ impl effectcraft_engine::Exporter for SlowExporter { } } +/// docs/mcp.md "Requests during a render": while a blocking render runs, every way of starting a +/// second render is refused (blocking, `wait: false`, inside a batch), and edits are applied to +/// the project without changing the running job, which renders the snapshot it started with. +#[test] +fn second_render_is_refused_and_edits_apply_during_a_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-mcp-busy-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (in_r, mut in_w) = std::io::pipe().unwrap(); + let (out_r, out_w) = std::io::pipe().unwrap(); + let server = std::thread::spawn(move || { + let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() }; + McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap(); + }); + let mut lines = BufReader::new(out_r).lines(); + let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() }; + let mut send = |v: Value| writeln!(in_w, "{v}").unwrap(); + let call = + |id: u64, name: &str, arguments: Value| json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})); + next(); + send(call(2, "command_run", json!({"id": "comp.new", "params": {"width": 64, "height": 64, "duration": 1}}))); + assert_eq!(next()["result"]["isError"], false); + let a = dir.join("a.mp4").to_string_lossy().to_string(); + send(call(3, "command_run", json!({"id": "renderQueue.add", "params": {"output": a}}))); + assert_eq!(next()["result"]["isError"], false); + send(call(4, "command_run", json!({"id": "renderQueue.render"}))); + send(call(5, "command_run", json!({"id": "renderQueue.render", "params": {"wait": false}}))); + send(call(6, "command_run", json!({"id": "renderQueue.render"}))); + send(call(7, "command_batch", json!({"steps": [{"id": "renderQueue.render", "params": {"wait": false}}]}))); + send(call(8, "command_run", json!({"id": "comp.new", "params": {"name": "Added while rendering", "width": 32, "height": 32, "duration": 1}}))); + let mut replies = std::collections::HashMap::new(); + while !replies.contains_key(&4) { + let m = next(); + replies.insert(m["id"].as_u64().unwrap(), m); + } + for id in [5, 6, 7] { + let m = &replies[&id]; + assert_eq!(m["result"]["isError"], true, "a second render is refused: {m}"); + let text = m["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("already in progress") || text.contains("is rendering"), "{m}"); + } + assert_eq!(replies[&8]["result"]["isError"], false, "edits are applied during a render"); + let done = &replies[&4]; + assert_eq!(done["result"]["isError"], false, "{done}"); + let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(v["items"].as_array().unwrap().len(), 1, "{v}"); + assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}"); + send(call(9, "doc_inspect", json!({}))); + let doc = next(); + assert!(doc.to_string().contains("Added while rendering"), "{doc}"); + drop(in_w); + server.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// docs/mcp.md "Progress and cancellation" over the stdio loop: a blocking `renderQueue.render` /// reports `notifications/progress` for its token, other requests are answered while it renders, /// and `notifications/cancelled` stops it, deletes the partial file and sends no response. @@ -991,7 +1109,6 @@ fn render_progress_and_cancel() { assert!(!b.exists(), "partial output deleted"); assert!(std::path::Path::new(&a).exists(), "completed earlier queue item is retained"); assert_eq!(v["items"][1]["statusLabel"], "User Stopped", "{v}"); - drop(send); drop(in_w); server.join().unwrap(); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3e30895b..12ea3167 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -319,6 +319,9 @@ fn set_property(b: &mut Backend, a: &Value) -> Result { match time { Some(t) => { p["time"] = t.clone(); + if let Some(tb) = get(a, "timeBase") { + p["timeBase"] = tb.clone(); + } b.exec("prop.addKey", p)?; } None => { @@ -351,6 +354,9 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result { if let Some(v) = get(k, "value") { p["value"] = v.clone(); } + if let Some(tb) = get(a, "timeBase") { + p["timeBase"] = tb.clone(); + } b.exec("prop.addKey", p)?; } let info = b.exec("prop.get", obj(&base))?; @@ -360,7 +366,11 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result { b.exec("comp.open", json!({"comp": c}))?; } let sel: Vec = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect(); - b.exec("keys.select", json!({"keys": sel}))?; + let mut select = json!({"keys": sel}); + if let Some(tb) = get(a, "timeBase") { + select["timeBase"] = tb.clone(); + } + b.exec("keys.select", select)?; match interp.to_ascii_lowercase().as_str() { "easyease" | "ease" => b.exec("keys.easyEase", json!({}))?, "easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?, @@ -592,7 +602,7 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "get_layer", - description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).", + description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).", bridge_only: false, schema: || { schema( @@ -628,6 +638,7 @@ static TOOLS: &[ToolDef] = &[ json!({ "layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(), "time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."}, + "timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."}, "expression": {"type": "string", "description": "Expression text; empty string removes the expression."} }), &["layer", "path"], @@ -644,6 +655,7 @@ static TOOLS: &[ToolDef] = &[ json!({ "layer": layer_s(), "path": path_s(), "comp": comp_s(), "time": {"type": "number", "description": "Key time in seconds (layer time)."}, + "timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."}, "value": value_s(), "keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."}, "interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]} diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 25984984..5be3c94e 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -171,8 +171,7 @@ const CC_COMPOSITE_MODES: [&str; 16] = [ "Stencil Alpha", ]; -/// Composites the original over/under/with the effect result. The original is the image as it -/// enters this effect (the host does not expose the pre-effect source). +/// Composites the original stack input over/under/with the result of preceding effects. fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0); let m = ctx.params.e("compositeOriginal"); @@ -194,9 +193,10 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { 15 => BlendMode::StencilAlpha, _ => BlendMode::Normal, }; - b.img.data.par_iter_mut().for_each(|px| { + let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale)); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let result = *px; - let orig = result.map(|v| v * op); + let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op); let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) }; o[3] = o[3].clamp(0.0, 1.0); if rgb_only { @@ -446,3 +446,53 @@ pub(crate) mod tests { assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5); } } + +#[cfg(test)] +mod original_tests { + use super::*; + use crate::{EffectEnv, EffectHost, Image, LayerPixels}; + struct Original(Buf); + impl EffectHost for Original { + fn original(&self) -> Option<&Buf> { + Some(&self.0) + } + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + #[test] + fn composite_preserves_alpha_opacity_and_padded_alignment() { + let spec = crate::find("ec.channel.cccomposite").unwrap(); + let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let host = Original(Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 0.5]), offset: [0.0; 2], scale: 1.0 }); + for (mode, opacity, rgb, want) in [ + (0, 100.0, false, [0.5, 0.0, 0.125, 0.625]), + (1, 100.0, false, [0.375, 0.0, 0.25, 0.625]), + (0, 50.0, false, [0.25, 0.0, 0.1875, 0.4375]), + (0, 100.0, true, [0.2, 0.0, 0.05, 0.25]), + ] { + params.values.insert("compositeOriginal".into(), Value::Enum(mode)); + params.values.insert("opacity".into(), num(opacity)); + params.values.insert("rgbOnly".into(), Value::Bool(rgb)); + let ctx = EffectCtx { + params: ¶ms, + time: 0.0, + layer_size: [1.0; 2], + seed: 0, + adjustment: false, + env: EffectEnv { host: Some(&host), ..Default::default() }, + }; + let mut input = Buf { img: Image::filled(1, 1, [0.0, 0.0, 0.25, 0.25]), offset: [0.0; 2], scale: 1.0 }; + input.pad(1); + let out = cc_composite(&ctx, input); + for (a, b) in out.img.get(1, 1).iter().zip(want) { + assert!((a - b).abs() < 1e-6, "{:?} vs {want:?}", out.img.get(1, 1)); + } + assert_eq!(out.img.get(0, 0), [0.0; 4]); + assert_eq!(out.offset, [1.0; 2]); + } + } +} diff --git a/crates/effects/src/fidelity.rs b/crates/effects/src/fidelity.rs new file mode 100644 index 00000000..e18803ee --- /dev/null +++ b/crates/effects/src/fidelity.rs @@ -0,0 +1,57 @@ +//! Deterministic rendered-frame comparison helpers for the fidelity corpus. + +use effectcraft_raster::Image; + +/// How far a rendered frame is from its reference. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameScore { + /// Pixels compared (the frames' width × height). + pub compared_pixels: usize, + /// Mean absolute difference over every channel sample. + pub mean_absolute_error: f64, + /// Largest absolute difference of any channel sample. + pub max_error: f32, +} + +/// Compare two premultiplied rendered frames. A size mismatch is reported as an error instead of +/// silently producing a misleading score. +pub fn compare_frames(actual: &Image, reference: &Image) -> Result { + if actual.width != reference.width || actual.height != reference.height || actual.data.len() != reference.data.len() { + return Err(format!("frame size mismatch: {}x{} vs {}x{}", actual.width, actual.height, reference.width, reference.height)); + } + let mut sum = 0.0f64; + let mut max = 0.0f32; + for (a, b) in actual.data.iter().zip(&reference.data) { + for (&x, &y) in a.iter().zip(b) { + let error = (x - y).abs(); + // A NaN would make the mean NaN, which a `mean > tolerance` check reads as a pass. + if !error.is_finite() { + return Err("frame has a non-finite sample".into()); + } + sum += f64::from(error); + max = max.max(error); + } + } + let compared_pixels = actual.data.len(); + let samples = compared_pixels.saturating_mul(4).max(1); + Ok(FrameScore { compared_pixels, mean_absolute_error: sum / samples as f64, max_error: max }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_pixel_error_and_rejects_size_mismatch() { + let mut actual = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]); + let reference = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]); + actual.data[1][0] = 0.5; + let score = compare_frames(&actual, &reference).expect("same size"); + assert_eq!(score.compared_pixels, 2); + assert!((score.mean_absolute_error - 0.0625).abs() < f64::EPSILON); + assert_eq!(score.max_error, 0.5); + assert!(compare_frames(&actual, &Image::new(1, 1)).is_err()); + actual.data[0][1] = f32::NAN; + assert!(compare_frames(&actual, &reference).is_err(), "NaN is an error, not a pass"); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index daa9b078..4bce0636 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -26,6 +26,7 @@ mod distort2; mod distort3; pub mod distort4; pub mod face_track; +pub mod fidelity; mod generate; mod generate2; mod generate3; @@ -299,8 +300,14 @@ pub struct LayerPixels { /// Services the renderer offers effects that look beyond their own pixels. pub trait EffectHost: Sync { + /// Pixels entering the effect stack, in effect space, before any effects. For an + /// adjustment layer this is the composition below it, in the effect working space. + fn original(&self) -> Option<&Buf> { + None + } + /// Layer `id` of the same composition at the current comp time. `masks_and_effects` - /// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references. + /// selects "Effects & Masks" over "Source". `None` for missing/recursive references; own Source reads are supported. fn layer(&self, id: u64, masks_and_effects: bool) -> Option; /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9e..c8fcf848 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index f0894182..c3416618 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -450,6 +450,17 @@ pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPix }) } +/// Resample a buffer into another buffer's pixel grid without stretching its layer space. +/// Used to composite the pre-effect image after earlier effects expand or shift the bounds. +pub fn fit_buffer(source: &crate::Buf, width: u32, height: u32, offset: [f64; 2], scale: f64) -> Image { + let inv = 1.0 / scale.max(1e-9); + gen_image(width, height, |x, y| { + let sx = (x as f64 + 0.5 - offset[0]) * inv * source.scale + source.offset[0]; + let sy = (y as f64 + 0.5 - offset[1]) * inv * source.scale + source.offset[1]; + source.img.sample_bilinear(sx, sy) + }) +} + /// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of /// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour). pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image { diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 7b830bf0..3d087e07 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::prop::prop_ref; +use super::prop::{KeyTimeBase, key_time, key_timing, prop_ref}; use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p}; use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query}; @@ -206,11 +206,13 @@ fn nudge(s: &mut Session, p: &Value) -> Result { /// Edit one key directly: move it in time and/or set its value (Graph Editor drags). fn set_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "keys.set")?; - let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?); - let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64); + let base = KeyTimeBase::from_params(p, "keys.set")?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?; + let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?; let value = p.get("value").cloned(); - let fr = s.project.comp(cid).map(|c| c.frame_rate); - s.edit("Edit Keyframe", merge_p(p), |proj, st| { + let nt = s.edit("Edit Keyframe", merge_p(p), |proj, st| { let l = super::layer_mut(proj, cid, lid)?; let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?; let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?; @@ -221,10 +223,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result { } let mut nt = old_t; if let Some(n) = new_t { - nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n); + nt = n; // Keys can't pass their neighbours or share a time. - let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN }; - let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX }; + let lo = if i > 0 { pr.keys[i - 1].time.0.saturating_add(1) } else { i64::MIN }; + let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0.saturating_sub(1) } else { i64::MAX }; nt = Tick(nt.0.clamp(lo, hi)); pr.keys[i].time = nt; } @@ -233,8 +235,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result { for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) { k.time = nt; } - Ok(json!({"time": nt.seconds()})) - }) + Ok(nt) + })?; + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + Ok(key_timing(layer, nt)) } fn nearest_key(keys: &[Keyframe], t: Tick) -> Option { @@ -449,7 +453,15 @@ pub fn specs() -> Vec { s, &json!({"frames": -10}) )), - cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key), + cmd!( + "keys.set", + "Edit Keyframe", + [], + None, + "{layer?, path|prop, time (layer s by default), newTime? (same base), timeBase?: layer|comp, value?, merge?}", + has_comp, + set_key + ), cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")), cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")), cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 6a561461..aa9e1be9 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -911,41 +911,7 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result { fn reload(s: &mut Session, p: &Value) -> Result { let items = items_p(s, p); - if s.importer.is_none() { - return Err(EngineError::Other("media import is not available in this build".into())); - } - let mut updates = vec![]; - for i in &items { - if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { - // An image sequence picks up frames added to (or removed from) its run. - let mut nf = match s.probe_footage(&f.path) { - Ok(n) => n, - Err(_) => Footage { missing: true, ..f.clone() }, - }; - // Keep the interpretation. - nf.alpha = f.alpha; - nf.loop_count = f.loop_count; - nf.pixel_aspect = f.pixel_aspect; - nf.start_timecode = f.start_timecode; - if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence { - nf.frame_rate = f.frame_rate; - nf.native_rate = f.native_rate; - nf.alphabetical = f.alphabetical; - nf.sync_sequence_duration(); - } - updates.push((*i, nf)); - } - } - let n = updates.len(); - s.edit("Reload Footage", None, |proj, _| { - for (i, f) in updates { - if let Some(it) = proj.item_mut(i) { - it.kind = ItemKind::Footage(f); - } - } - Ok(()) - })?; - s.events.push(crate::Event::PurgeCaches); + let n = crate::footage_reload::reload_items(s, &items)?; Ok(json!({"reloaded": n})) } @@ -1009,6 +975,15 @@ pub fn specs() -> Vec { has_items, crate::footage_check::command ), + cmd!( + "footage.reloadChanged", + "Reload Changed Footage", + [], + None, + "{} — reload the footage whose file changed on disk since it was last seen (size or modification time; Settings ▸ Import ▸ Automatically Reload Footage runs this in the background; image sequences only with All Footage); files seen for the first time are only remembered. Returns {files, reloaded}", + has_items, + crate::footage_reload::command + ), cmd!( "file.runScript", "Run Script File...", diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 296160d1..57fdb60a 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -106,5 +106,15 @@ pub fn specs() -> Vec { fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers), fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always), fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always), + // Project panel: delete the selected items without the prompt that Delete shows when + // compositions use them. The UI runs `project.delete` (agents can run it directly). + fe!( + "project.deleteWithoutConfirmation", + "Delete Project Items Without Confirmation", + [], + Some("Shift+Delete"), + "{} — the Project panel's selection, with the layers that use it; one undo step", + always + ), ] } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index eaf70cf7..11dcb4dc 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -173,7 +173,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result { } let layer_name = str_p(p, "name").map(str::to_string); apply_text_params("layer.newText", &q, &mut doc, None)?; - let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); + let mut pos = position_p(p, "layer.newText")?; if let Some([x, y, w, h]) = bx { let (w, h) = (w.abs().max(1.0), h.abs().max(1.0)); doc.box_size = Some([w, h]); @@ -542,7 +542,7 @@ fn track_matte(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// How a 2D layer's transform changes so it stays where it is when its parent changes. +/// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { pub layer: LayerId, /// Maps Position from the old parent's space into the new one's. @@ -551,12 +551,80 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], + /// 3D AV layers retain their world-space anchor, including Z. + position_3d: Option, + /// A uniform relative parent transform can preserve static Orientation and Scale. + pose_3d: Option<([f64; 3], f64)>, } impl ParentFix { /// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space. pub fn apply(&self, l: &mut Layer) { let Some(tr) = l.props.sub_mut("transform") else { return }; + if let Some(m) = self.position_3d { + // Spatial Position speed measures distance along its path. Its parent-space + // unit changes even when the child's Orientation cannot be re-expressed. + let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let map_position = |v: &KV| { + let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); + KV::Vec3([q.x, q.y, q.z]) + }; + // Read all separated axes before writing any of them. + let names = ["positionX", "positionY", "positionZ"]; + if tr.get("positionX").is_some() { + let axes = names.map(|name| tr.get(name).cloned()); + let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::Vec3::from(current)); + for (i, name) in names.into_iter().enumerate() { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); + for key in &mut pr.keys { + let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0)); + let q = m.apply(effectcraft_geom::Vec3::from(p)); + key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0)); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + pr.value = map_position(&pr.value); + for key in &mut pr.keys { + key.value = map_position(&key.value); + // Spatial tangents are vectors, so the parent's translation is excluded. + for tangent in [&mut key.spatial_in, &mut key.spatial_out] { + let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); + *tangent = [v.x, v.y, v.z]; + } + } + if let Some(factor) = position_speed + .filter(|_| pr.spatial) + .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) + { + for key in &mut pr.keys { + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } + if let Some((orientation, factor)) = self.pose_3d { + if let Some(pr) = tr.get_mut("orientation") { + pr.value = KV::Vec3(orientation); + } + if let Some(pr) = tr.get_mut("scale") { + let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); + pr.value = scaled(&pr.value); + for key in &mut pr.keys { + key.value = scaled(&key.value); + // Scale's temporal speeds use the same units as its values. + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } + return; + } let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { pr.value = f(&pr.value); for key in &mut pr.keys { @@ -606,7 +674,42 @@ impl ParentFix { } } -/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is +/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal +/// rotation to static Orientation using the existing Rz * Ry * Rx order. +fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { + use effectcraft_geom::{Mat4, Vec3, vec3}; + if !orientation.iter().all(|v| v.is_finite()) { + return None; + } + let a = m.0; + let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; + let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); + if !magnitude.is_finite() || magnitude <= 0.0 { + return None; + } + let c = columns.map(|c| c / magnitude); + if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 { + return None; + } + let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x); + let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude }; + let mut rotation = Mat4::IDENTITY; + for row in 0..3 { + for col in 0..3 { + rotation.0[row][col] = a[row][col] / factor; + } + } + if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { + return Some((orientation, factor)); + } + let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + let sy = (-r[2][0]).clamp(-1.0, 1.0); + let y = sy.asin(); + let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) +} + +/// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is /// re-expressed in the new parent's space (`None`: the composition), evaluated at the current /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { @@ -625,16 +728,59 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op let new_m = space(parent); let Some(inv) = new_m.inverse() else { return vec![] }; let (rn, sxn, syn) = decompose(&new_m); - comp.layers + let mut fixes: Vec<_> = comp + .layers .iter() .filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) .map(|l| { let old_m = space(l.parent); let (ro, sxo, syo) = decompose(&old_m); let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; - ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale } + ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None } }) - .collect() + .collect(); + let space_3d = |id: Option| { + id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| { + // Parent transforms do not inherit the parent's source pixel-aspect stretch. + let world = ectx.world_matrix(layer); + let par = ectx.par_ratio(layer); + let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 }); + world + * effectcraft_geom::Mat4::translate(anchor) + * effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0)) + * effectcraft_geom::Mat4::translate(-anchor) + }) + }; + if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) { + let m = inv * space_3d(layer.parent); + if m.0.iter().flatten().all(|v| v.is_finite()) { + let pose_3d = layer.transform().and_then(|tr| { + let orientation = tr.get("orientation")?; + let scale = tr.get("scale")?; + // Animated Euler Orientation and auto-orient cannot be re-expressed by + // prepending a constant Euler value. Expression-driven values stay authored. + if !orientation.keys.is_empty() + || orientation.expr.is_some() + || scale.expr.is_some() + || layer.auto_orient != effectcraft_project::AutoOrient::Off + { + return None; + } + let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; + let factor = pose.1; + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); + (values_finite && speeds_finite).then_some(pose) + }); + fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); + } + } + } + fixes } /// Validate the proposed ancestor chain before any authored edit, including when compensation @@ -1347,7 +1493,7 @@ pub fn specs() -> Vec { has_layers, track_matte ), - cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null}", has_layers, set_parent), + cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent), cmd!( "layer.timing", "Layer Timing", @@ -1402,7 +1548,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 423aaea2..98fe0cb4 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -48,6 +48,7 @@ mod prop_groups; mod proxy; pub mod puppet; mod query; +mod relink; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; @@ -169,6 +170,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(key_labels::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); + v.extend(relink::specs()); v.extend(lottie::specs()); v.extend(timeline::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 063b179f..cf459393 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -2,10 +2,10 @@ use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key}; use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid}; -use effectcraft_time::Tick; +use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). @@ -13,10 +13,23 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay let (cid, lid) = layer_p(s, p, cmd)?; let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; if let Some(u) = p.get("prop").and_then(Value::as_u64) { - return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| bad(cmd, format!("no property @{u}"))); + return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| { + if layer.props.find_group(u).is_some() { + bad(cmd, format!("@{u} is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) + } else { + bad(cmd, format!("no property @{u}")) + } + }); } let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?; - let pr = layer.props.prop(path).ok_or_else(|| bad(cmd, format!("no property `{path}`")))?; + let pr = layer.props.prop(path).ok_or_else(|| { + let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); + if group.is_some() { + bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) + } else { + bad(cmd, format!("no property `{path}`")) + } + })?; Ok((cid, lid, pr.uid)) } @@ -29,7 +42,7 @@ fn with_prop( uid: Uid, f: impl FnOnce(&mut Property, Tick) -> Result, ) -> Result { - let t = s.time(); + let t = s.time_of(cid); let linear = s.prefs.general.default_spatial_linear; s.edit(label, merge, |proj, _| { let l = layer_mut(proj, cid, lid)?; @@ -50,12 +63,106 @@ fn with_prop( }) } -/// An explicit key time (layer seconds) moved onto the nearest comp frame. -fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option { - let t = Tick::from_seconds_f64(f_p(p, "time")?); - let comp = s.project.comp(cid)?; - let l = comp.layer(lid)?; - Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t)))) +/// Explicit command key times default to layer time; readers and the CTI use comp time. +#[derive(Clone, Copy)] +pub(crate) enum KeyTimeBase { + Layer, + Comp, +} + +impl KeyTimeBase { + pub(crate) fn from_params(p: &Value, cmd: &str) -> Result { + match p.get("timeBase") { + None => Ok(Self::Layer), + Some(Value::String(b)) if b == "layer" => Ok(Self::Layer), + Some(Value::String(b)) if b == "comp" => Ok(Self::Comp), + Some(_) => Err(bad(cmd, "`timeBase` must be layer or comp")), + } + } +} + +/// Convert a supplied time to stored layer ticks, snapping writes in the comp's frame grid. +pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &str, base: KeyTimeBase, cmd: &str, snap: bool) -> Result> { + let Some(value) = p.get(param) else { return Ok(None) }; + let seconds = value + .as_f64() + .filter(|t| t.is_finite() && *t >= Tick::MIN.seconds() && *t <= Tick::MAX.seconds()) + .ok_or_else(|| bad(cmd, format!("`{param}` must be finite seconds within the supported time range")))?; + if !layer.stretch.is_finite() || layer.stretch == 0.0 { + return Err(bad(cmd, "the layer has an invalid time stretch")); + } + let t = Tick::from_seconds_f64(seconds); + let conversion_error = || bad(cmd, format!("`{param}` converts outside the supported time range")); + let in_range = |ticks: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&ticks); + // Validate before Layer's saturating conversion: otherwise a huge stretch can silently + // clamp an intended key to a different comp time while still producing a valid Tick. + let scaled_tick = |ticks: f64| -> Result { + if !ticks.is_finite() || ticks < Tick::MIN.0 as f64 || ticks > Tick::MAX.0 as f64 { + return Err(conversion_error()); + } + let ticks = ticks as i64; + if !in_range(ticks) { + return Err(conversion_error()); + } + Ok(Tick(ticks)) + }; + let normal = (layer.stretch - 100.0).abs() < 1e-9; + let ct = match base { + KeyTimeBase::Layer => { + let offset = if normal { t } else { scaled_tick(t.0 as f64 * layer.stretch / 100.0)? }; + Tick(layer.start_time.0.checked_add(offset.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?) + } + KeyTimeBase::Comp => t, + }; + let ct = if snap { frame_rate.snap_nearest(ct) } else { ct }; + if !in_range(ct.0) { + return Err(conversion_error()); + } + let offset = ct.0.checked_sub(layer.start_time.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?; + let lt = if normal { Tick(offset) } else { scaled_tick(offset as f64 * 100.0 / layer.stretch)? }; + // Unsnapped legacy layer times remain exact, rather than making a float round-trip. + Ok(Some(if !snap && matches!(base, KeyTimeBase::Layer) { t } else { lt })) +} + +pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { + let ct = layer.comp_time(t); + let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); + if !layer.is_active_at(ct) { + out["warning"] = json!(format!( + "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", + ct.seconds(), + layer.in_point.seconds(), + layer.out_point.seconds() + )); + } + out +} + +/// Omitted bases keep the original JSON reply; explicit bases opt in to timing metadata. +fn key_reply(p: &Value, mut timing: Value, field: &str, result: Value) -> Value { + if p.get("timeBase").is_some() { + timing[field] = result; + timing + } else { + result + } +} + +fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value, cmd: &str) -> Result> { + let base = KeyTimeBase::from_params(p, cmd)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + key_time(layer, comp.frame_rate, p, "time", base, cmd, true) +} + +fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Value) -> Value { + if p.get("timeBase").is_some() { + let mut timing = key_timing(layer, time); + timing["animated"] = json!(pr.is_animated()); + key_reply(p, timing, "value", value) + } else { + value + } } fn set(s: &mut Session, p: &Value) -> Result { @@ -87,7 +194,7 @@ fn set(s: &mut Session, p: &Value) -> Result { return Ok(json!(out)); } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); - let at = snapped_key_time(s, cid, lid, p); + let at = snapped_key_time(s, cid, lid, p, "prop.set")?; let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { let cur = pr.value_at(lt); let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?; @@ -100,7 +207,12 @@ fn set(s: &mut Session, p: &Value) -> Result { pr.set_value_at(at.unwrap_or(lt), nv.clone()); Ok(nv.to_json()) })?; - Ok(out) + if p.get("timeBase").is_none() { + return Ok(out); + } + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = layer.props.find(uid).ok_or_else(|| bad("prop.set", "property vanished"))?; + Ok(property_reply(p, layer, pr, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid))), out)) } /// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression. @@ -108,9 +220,18 @@ fn get(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.get")?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?; - let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); - let keys: Vec = - pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect(); + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); + let keys: Vec = pr + .keys + .iter() + .map(|k| { + let mut info = key_timing(l, k.time); + info["value"] = k.value.to_json(); + info["in"] = json!(k.in_interp.label()); + info["out"] = json!(k.out_interp.label()); + info + }) + .collect(); let raw = pr.value_at(l.layer_time(t)); let mut out = json!({ "layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(), @@ -156,9 +277,9 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result { fn add_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?; - let at = snapped_key_time(s, cid, lid, p); + let at = snapped_key_time(s, cid, lid, p, "prop.addKey")?; let value = p.get("value").cloned(); - with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| { + let count = with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| { let t = at.unwrap_or(lt); let cur = pr.value_at(t); let v = match &value { @@ -171,7 +292,12 @@ fn add_key(s: &mut Session, p: &Value) -> Result { } set_key(&mut pr.keys, k); Ok(json!(pr.keys.len())) - }) + })?; + if p.get("timeBase").is_none() { + return Ok(count); + } + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + Ok(key_reply(p, key_timing(layer, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid)))), "count", count)) } fn toggle_key(s: &mut Session, p: &Value) -> Result { @@ -233,8 +359,38 @@ fn toggle_transform_key(s: &mut Session, p: &Value) -> Result { Ok(Value::Array(added)) } +/// Add, toggle or set one property's expression (`def` is the starting text of a new one). +fn apply_expr(pr: &mut Property, text: &Option, enabled: Option, def: &str) { + match (text, enabled) { + (Some(t), _) if t.trim().is_empty() => pr.expr = None, + (Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }), + (None, Some(e)) => { + if let Some(x) = &mut pr.expr { + x.enabled = e; + } else if e { + pr.expr = Some(effectcraft_project::Expression { text: def.to_string(), enabled: true }); + } + } + (None, None) => { + pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.to_string(), enabled: true }) }; + } + } +} + +/// `{layer?, path|prop, ...}` for one property, or (Animation > Add Expression) no `path`/`prop` +/// for the properties selected in the timeline, all in one undo step. fn set_expr(s: &mut Session, p: &Value) -> Result { - let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?; + const C: &str = "prop.setExpression"; + let explicit = p.get("path").is_some() || p.get("prop").is_some(); + let targets: Vec<(ItemId, LayerId, Uid)> = if explicit { + vec![prop_ref(s, p, C)?] + } else { + let cid = comp_id(s, p)?; + selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect() + }; + if targets.is_empty() { + return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)")); + } let text = p.get("expression").and_then(Value::as_str).map(str::to_string); let mut enabled = b_p(p, "enabled"); // A syntax error keeps the text but disables the expression (AE shows the warning bar). @@ -246,24 +402,22 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { enabled = Some(false); } // Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`). - let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into()); - let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { - match (&text, enabled) { - (Some(t), _) if t.trim().is_empty() => pr.expr = None, - (Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }), - (None, Some(e)) => { - if let Some(x) = &mut pr.expr { - x.enabled = e; - } else if e { - pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true }); - } - } - (None, None) => { - pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) }; - } + let defs: Vec = targets + .iter() + .map(|(cid, lid, uid)| { + s.project.comp(*cid).and_then(|c| c.layer(*lid).and_then(|l| super::link::reference(c, l, l, *uid, true))).unwrap_or_else(|| "value".into()) + }) + .collect(); + let results = s.edit("Expression", merge_p(p), |proj, _| { + let mut out = vec![]; + for ((cid, lid, uid), def) in targets.iter().zip(&defs) { + let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?; + apply_expr(pr, &text, enabled, def); + out.push(json!(pr.expr.as_ref().map(|e| e.text.clone()))); } - Ok(json!(pr.expr.as_ref().map(|e| e.text.clone()))) + Ok(out) })?; + let r = if explicit { results.into_iter().next().unwrap_or(Value::Null) } else { Value::Array(results) }; if let Some(e) = error { s.toast(format!("Expression disabled: {e}")); return Ok(json!({"expression": r, "error": e})); @@ -346,6 +500,24 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Deselect only the named property, preserving unrelated property, layer, key and +/// mask-vertex selections. Selected keys also imply selectedProperties in the script API. +fn deselect_prop(s: &mut Session, params: &Value) -> Result { + // Groups (masks, effects) are selected by uid too, so resolve the uid as prop.select does. + let (_, lid) = layer_p(s, params, "prop.deselect")?; + let uid = match params.get("prop").and_then(Value::as_u64) { + Some(u) => u, + None => prop_ref(s, params, "prop.deselect")?.2, + }; + let mask = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(s.time()))).map(|(mask, _)| mask); + s.state.selected_props.retain(|(layer, prop)| *layer != lid || *prop != uid); + s.state.selected_keys.retain(|key| key.layer != lid || key.prop != uid); + if let Some(mask) = mask { + s.state.selected_vertices.retain(|v| v.layer != lid || v.mask != mask); + } + Ok(Value::Null) +} + /// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number /// of points at layer time `lt`. fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> { @@ -366,6 +538,7 @@ fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> { // ---------------------------------------------------------------- keyframes fn select_keys(s: &mut Session, p: &Value) -> Result { + let base = KeyTimeBase::from_params(p, "keys.select")?; let comp = s.active_comp().ok_or(EngineError::NoComp)?; let mut sel = vec![]; if let Some(Value::Array(keys)) = p.get("keys") { @@ -382,7 +555,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))), } .ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?; - let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?; + let t = key_time(ly, comp.frame_rate, k, "time", base, "keys.select", false)?.ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?; // Snap to the stored key time. let kt = pr .keys @@ -432,7 +605,28 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { } } } - Ok(json!(s.state.selected_keys.len())) + if p.get("timeBase").is_none() { + return Ok(json!(s.state.selected_keys.len())); + } + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let keys: Vec = s + .state + .selected_keys + .iter() + .filter_map(|k| { + let layer = comp.layer(k.layer)?; + let mut timing = key_timing(layer, k.time); + timing["layer"] = json!(k.layer.0); + timing["prop"] = json!(k.prop); + Some(timing) + }) + .collect(); + let warnings: Vec<&str> = keys.iter().filter_map(|k| k.get("warning").and_then(Value::as_str)).collect(); + let mut out = json!({"count": s.state.selected_keys.len(), "keys": keys}); + if !warnings.is_empty() { + out["warning"] = json!(warnings.join(" ")); + } + Ok(out) } /// Apply `f` to every selected key; keeps the selection pointing at moved keys. @@ -441,49 +635,53 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im let sel = s.state.selected_keys.clone(); s.edit(label, merge, |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut new_sel = vec![]; + let mut remapped: std::collections::HashMap = Default::default(); let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); for k in &sel { groups.entry((k.layer, k.prop)).or_default().push(k.time); } for ((lid, uid), times) in groups { let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; - // Process in an order that avoids collisions when moving. + // Process each stored key once, in an order that avoids collisions when moving. let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect(); idx.sort_by_key(|x| x.0); - let mut moved: Vec = vec![]; - let mut keep_times = vec![]; - for (i, _) in idx.iter().rev() { - let mut t = pr.keys[*i].time; + idx.dedup_by_key(|x| x.0); + let mut moved: Vec<(Tick, Keyframe)> = vec![]; + // Target time -> surviving source time. A moved key replaces an existing key at + // its destination, including its selection identity. + let mut targets: std::collections::HashMap = Default::default(); + for (i, old) in idx.iter().rev() { + let Some(key) = pr.keys.get(*i) else { continue }; + let mut t = key.time; if f(&mut pr.keys, *i, &mut t) { - let mut k = pr.keys.remove(*i); - k.time = t; - moved.push(k); - } else if *i < pr.keys.len() { - keep_times.push(pr.keys[*i].time); + if *i < pr.keys.len() { + let mut k = pr.keys.remove(*i); + k.time = t; + moved.push((*old, k)); + } + } else if key_at(&pr.keys, *old).is_some() { + targets.insert(*old, *old); } } - for k in moved { - new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); + for (old, k) in moved { + targets.insert(k.time, old); match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { Ok(j) => pr.keys[j] = k, Err(j) => pr.keys.insert(j, k), } } - for t in keep_times { - new_sel.push(KeyRef { layer: lid, prop: uid, time: t }); - } - // Roving keys follow their neighbours: re-time and keep the selection on them. - let before: Vec = pr.keys.iter().map(|k| k.time).collect(); - if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { - for r in new_sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { - if let Some(i) = before.iter().position(|t| *t == r.time) { - r.time = pr.keys[i].time; - } + // Resolve exact stored indices before roving changes their times. Deleted or + // stale references cannot select a neighbouring key. + let tracked: Vec<(Tick, usize)> = targets.into_iter().filter_map(|(at, old)| key_at(&pr.keys, at).map(|i| (old, i))).collect(); + effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial); + for (old, i) in tracked { + if let Some(key) = pr.keys.get(i) { + remapped.insert(KeyRef { layer: lid, prop: uid, time: old }, KeyRef { layer: lid, prop: uid, time: key.time }); } } } - st.selected_keys = new_sel; + let mut seen = std::collections::HashSet::new(); + st.selected_keys = sel.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect(); Ok(json!(st.selected_keys.len())) }) } @@ -796,9 +994,17 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get), - cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set), + cmd!( + "prop.set", + "Set Property Value", + [], + None, + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}", + has_layers, + set + ), cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim), - cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key), + cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key), cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), cmd!( "keys.toggleTransform", @@ -809,7 +1015,15 @@ pub fn specs() -> Vec { has_layers, toggle_transform_key ), - cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr), + cmd!( + "prop.setExpression", + "Add Expression", + ["Animation"], + Some("Alt+Shift+="), + "{layer?, path|prop?, expression?, enabled?} (no path/prop: the selected properties)", + has_layers, + set_expr + ), cmd!( "prop.reset", "Reset Property", @@ -820,6 +1034,7 @@ pub fn specs() -> Vec { reset ), cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop), + cmd!("prop.deselect", "Deselect Property", [], None, "{layer?, path|prop}", has_layers, deselect_prop), cmd!( "prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", @@ -834,7 +1049,7 @@ pub fn specs() -> Vec { "Select Keyframes", [], None, - "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}", + "{keys: [{layer, prop: uid | path (or `path`), time (layer s by default)}], timeBase?: layer|comp, add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}", has_comp, select_keys ), diff --git a/crates/engine/src/commands/relink.rs b/crates/engine/src/commands/relink.rs new file mode 100644 index 00000000..1c095dda --- /dev/null +++ b/crates/engine/src/commands/relink.rs @@ -0,0 +1,414 @@ +//! Recover moved media without replacing interpretation, sequence ranges or layer timing. + +use std::collections::BTreeMap; +use std::path::Path; + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, str_p}; +use crate::{Result, Session, cmd}; + +const COMMAND: &str = "file.relinkFootage"; +const ENTRY_LIMIT: usize = 100_000; + +fn enabled(_: &Session) -> std::result::Result<(), String> { + if cfg!(target_arch = "wasm32") { Err("folder relinking requires the desktop filesystem".into()) } else { Ok(()) } +} + +fn files(folder: &Path, ctl: &crate::jobs::TaskCtl) -> std::result::Result>, String> { + let mut pending = vec![folder.to_path_buf()]; + let mut found: BTreeMap> = BTreeMap::new(); + let mut count = 0usize; + while let Some(dir) = pending.pop() { + ctl.message(dir.display().to_string()); + let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + count = count.saturating_add(1); + if count > ENTRY_LIMIT { + return Err(format!("search exceeds {ENTRY_LIMIT} entries; choose a smaller folder")); + } + let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; + let ty = entry.file_type().map_err(|e| format!("{}: {e}", entry.path().display()))?; + // Do not follow symlinks: no cycles or traversal outside the chosen tree. + if ty.is_dir() { + pending.push(entry.path()); + } else if ty.is_file() + && let (Some(name), Some(path)) = (entry.file_name().to_str(), entry.path().to_str()) + { + found.entry(name.to_string()).or_default().push(path.to_string()); + } + } + } + for paths in found.values_mut() { + paths.sort(); + } + Ok(found) +} + +fn compatible(old: &Footage, new: &Footage) -> bool { + let kind = if old.kind == FootageKind::Sequence { FootageKind::Still } else { old.kind }; + kind == new.kind + && old.width == new.width + && old.height == new.height + && old.has_video == new.has_video + && old.has_audio == new.has_audio + && (old.kind == FootageKind::Audio || (old.width > 0 && old.height > 0)) + && (old.codec.is_empty() || old.codec == new.codec) + && (!matches!(old.kind, FootageKind::Video | FootageKind::Audio) + || (old.native_rate.unwrap_or(old.frame_rate) == new.frame_rate && old.frame_rate.frame_at(old.duration) == new.frame_rate.frame_at(new.duration))) +} + +fn sequence_at(old: &Footage, path: &str) -> Option> { + let parent = Path::new(path).parent()?; + old.sequence + .iter() + .map(|file| { + let mapped = parent.join(Path::new(file).file_name()?); + std::fs::symlink_metadata(&mapped).is_ok_and(|m| m.is_file()).then(|| mapped.to_str().map(str::to_string)).flatten() + }) + .collect() +} + +fn run(s: &mut Session, p: &Value) -> Result { + let folder = str_p(p, "folder").filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(COMMAND, "missing `folder`"))?; + let folder = std::fs::canonicalize(folder).map_err(|e| bad(COMMAND, format!("{folder}: {e}")))?; + if !folder.is_dir() { + return Err(bad(COMMAND, "folder must be a directory")); + } + let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?; + let services = s.services.clone(); + let missing: Vec<(ItemId, Footage)> = s + .project + .items + .values() + .filter_map(|item| match &item.kind { + ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())), + _ => None, + }) + .collect(); + let dry_run = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + s.spawn_task("relink", "Relinking missing footage", wait, move |ctl| { + let index = files(&folder, ctl)?; + let mut rows = vec![]; + let mut updates = vec![]; + for (n, (id, old)) in missing.iter().enumerate() { + if !ctl.progress(n as u64, missing.len() as u64) { + return Err(crate::render_queue::CANCELLED.into()); + } + let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio) + && old.layer.is_none() && old.page == 0; + let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or(""); + let paths = index.get(name).cloned().unwrap_or_default(); + let mut candidates = vec![]; + let mut accepted = vec![]; + for path in paths { + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + let mut reason = "source type requires manual replacement".to_string(); + let mut confidence = "low"; + if supported { + match importer.probe(&path) { + Ok(f) if compatible(old, &f) => { + if old.kind != FootageKind::Sequence || (!old.sequence.is_empty() && sequence_at(old, &path).is_some()) { + confidence = "high"; + reason = "exact filename and compatible source metadata".into(); + accepted.push(path.clone()); + } else { + reason = "sequence is incomplete".into(); + } + } + Ok(_) => reason = "source metadata differs".into(), + Err(e) => reason = format!("cannot decode candidate: {e}"), + } + } + if ctl.cancelled() { + return Err(crate::render_queue::CANCELLED.into()); + } + candidates.push(json!({"path": path, "confidence": confidence, "reason": reason})); + } + let status = if !supported { "unsupported" } else if candidates.is_empty() { "notFound" } else if candidates.len() > 1 { "ambiguous" } else if accepted.len() == 1 { "matched" } else { "incompatible" }; + if status == "matched" && let Some(path) = accepted.first() { + let mut new = old.clone(); + new.path = path.clone(); + if old.kind == FootageKind::Sequence { + new.sequence = sequence_at(old, path).ok_or_else(|| "sequence changed during search; run the search again".to_string())?; + } + new.missing = false; + updates.push((*id, old.clone(), new)); + } + rows.push(json!({"item": id.0, "original": old.path, "status": status, "candidates": candidates})); + } + ctl.progress(missing.len() as u64, missing.len() as u64); + let apply: crate::jobs::Apply = Box::new(move |s| { + // A background search must not overwrite edits made while it was running. + for (id, old, new) in &updates { + if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old)) + || services.exists(&old.path) || !services.exists(&new.path) + || (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p))) + { + return Err(bad(COMMAND, "footage changed during search; run the search again")); + } + } + let matched = updates.len(); + if !dry_run && !updates.is_empty() { + s.edit("Relink Missing Footage", None, |project, _| { + for (id, _, new) in updates { + if let Some(item) = project.item_mut(id) { + item.kind = ItemKind::Footage(new); + } + } + Ok(()) + })?; + } + let unresolved = rows.len().saturating_sub(matched); + let toast = format!("{} {matched} high-confidence match(es); {unresolved} unresolved. Duplicate filenames are left for Replace Footage.", if dry_run { "Found" } else { "Relinked" }); + Ok(json!({"dryRun": dry_run, "matched": matched, "relinked": if dry_run { 0 } else { matched }, "unresolved": unresolved, "items": rows, "toast": toast})) + }); + Ok(apply) + }) +} + +pub fn specs() -> Vec { + vec![cmd!( + "file.relinkFootage", + "Relink Missing Footage...", + ["File", "Dependencies"], + None, + "{folder: string (recursive desktop search, no symlinks; max 100000 entries), dryRun?: bool (report only), wait?: bool (return results inline; otherwise jobs.list/jobs.wait)} — relinks only unique exact filenames with compatible metadata; preserves interpretation and sequence ranges. Reports high/low confidence and ambiguous, incompatible, unsupported or notFound items.", + enabled, + run + )] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> std::result::Result { + if std::fs::read(path).map_err(|e| e.to_string())? != b"valid" { + return Err("invalid media".into()); + } + Ok(Footage { path: path.into(), width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + } + } + + struct Fixture(std::path::PathBuf); + impl Fixture { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!("ec-relink-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + std::fs::create_dir(&dir).unwrap(); + Self(std::fs::canonicalize(dir).unwrap()) + } + fn file(&self, name: &str, bytes: &[u8]) -> String { + let path = self.0.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + path.to_str().unwrap().into() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn setup(dir: &Fixture, name: &str) -> (Session, ItemId, Footage) { + let mut s = Session { importer: Some(Arc::new(Probe)), ..Default::default() }; + let path = dir.file(&format!("old/{name}"), b"valid"); + let id = s.execute("file.import", json!({"paths": [path], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); + let id = ItemId(id); + let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind else { panic!("footage") }; + f.invert_alpha = true; + f.loop_count = 3; + f.start_timecode = Some(1001); + let old = f.clone(); + std::fs::remove_file(&old.path).unwrap(); + (s, id, old) + } + + fn search(s: &mut Session, dir: &Fixture, dry: bool) -> Value { + s.execute(COMMAND, json!({"folder": dir.0.join("new"), "wait": true, "dryRun": dry})).unwrap() + } + + fn get(s: &Session, id: ItemId) -> Footage { + let ItemKind::Footage(f) = &s.project.item(id).unwrap().kind else { panic!("footage") }; + f.clone() + } + + #[test] + fn recursive_relink_preserves_interpretation_and_supports_undo_redo() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + let new = dir.file("new/deep/shots/plate.png", b"valid"); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": id.0})).unwrap(); + let key = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap(); + let ctx = crate::render::eval::EvalCtx::new(&s.project, cid, c, crate::time::Tick::ZERO); + crate::render::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 0).unwrap() + }; + let original_key = key(&s); + let revision = s.revision; + let before = s.history.undo.len(); + let report = search(&mut s, &dir, true); + assert_eq!(report["items"][0]["candidates"][0]["confidence"], "high"); + assert_eq!(report["relinked"], 0); + assert_eq!(get(&s, id), old); + assert_eq!(s.history.undo.len(), before); + assert_eq!(search(&mut s, &dir, false)["relinked"], 1); + let expected = Footage { path: new, missing: false, ..old.clone() }; + assert_eq!(get(&s, id), expected); + assert!(s.revision > revision); + assert_ne!(key(&s), original_key, "restored footage cannot reuse the missing source's rendered layer"); + assert_eq!(s.history.undo.len(), before + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(get(&s, id), old); + assert_eq!(key(&s), original_key); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(get(&s, id), expected); + } + + #[test] + fn duplicates_and_invalid_media_are_reported_without_edits() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/a/plate.png", b"valid"); + let b = dir.file("new/b/plate.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "ambiguous"); + assert_eq!(get(&s, id), old); + std::fs::remove_file(b).unwrap(); + dir.file("new/a/plate.png", b"corrupt"); + let r = search(&mut s, &dir, false); + assert_eq!(r["items"][0]["status"], "incompatible"); + assert_eq!(r["items"][0]["candidates"][0]["confidence"], "low"); + assert_eq!(get(&s, id), old); + std::fs::remove_file(dir.0.join("new/a/plate.png")).unwrap(); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound"); + } + + #[test] + fn sequence_relink_preserves_picked_range_and_requires_every_saved_frame() { + let dir = Fixture::new(); + let (mut s, id, mut old) = setup(&dir, "shot_0002.png"); + old.kind = FootageKind::Sequence; + old.sequence = vec![old.path.clone(), dir.0.join("old/shot_0004.png").to_str().unwrap().into()]; + Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind = ItemKind::Footage(old.clone()); + dir.file("new/shot_0002.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 0); + dir.file("new/shot_0004.png", b"valid"); + dir.file("new/shot_0001.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 1); + let f = get(&s, id); + assert_eq!(f.sequence.len(), 2); + assert!(f.sequence[0].ends_with("shot_0002.png")); + assert!(f.sequence[1].ends_with("shot_0004.png")); + assert_eq!(f.start_timecode, old.start_timecode); + } + + #[test] + fn invalid_folder_errors_and_existing_media_is_untouched() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + assert!(s.execute(COMMAND, json!({"folder": dir.0.join("absent"), "wait": true})).is_err()); + assert!(s.execute(COMMAND, json!({"wait": true})).is_err()); + dir.file("old/plate.png", b"valid"); + dir.file("new/plate.png", b"valid"); + assert_eq!(search(&mut s, &dir, false)["relinked"], 0); + assert_eq!(get(&s, id), old); + } + + struct BlockingProbe { + started: std::sync::mpsc::Sender<()>, + release: std::sync::Mutex>, + calls: Arc, + } + impl crate::Importer for BlockingProbe { + fn probe(&self, path: &str) -> std::result::Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.started.send(()).map_err(|e| e.to_string())?; + self.release.lock().map_err(|e| e.to_string())?.recv_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?; + Probe.probe(path) + } + } + + #[test] + fn cancellation_between_candidate_probes_leaves_project_untouched() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/a/plate.png", b"valid"); + dir.file("new/b/plate.png", b"valid"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: calls.clone() })); + let r = s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!(s.cancel_job(r["job"].as_str().unwrap())); + release_tx.send(()).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); + assert_eq!(get(&s, id), old); + assert_eq!(s.job_log.last().unwrap().status, "cancelled"); + } + + #[test] + fn background_result_does_not_overwrite_interpretation_edited_during_search() { + let dir = Fixture::new(); + let (mut s, id, old) = setup(&dir, "plate.png"); + dir.file("new/plate.png", b"valid"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: Arc::default() })); + s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap(); + started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + s.execute("file.interpretFootage", json!({"items": [id.0], "invertAlpha": false})).unwrap(); + release_tx.send(()).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(get(&s, id).path, old.path); + assert!(!get(&s, id).invert_alpha); + assert_eq!(s.job_log.last().unwrap().status, "failed"); + } + + #[test] + fn movie_confidence_checks_native_frame_count_and_rate() { + let new = Footage { + kind: FootageKind::Video, + width: 64, + height: 32, + has_video: true, + frame_rate: crate::time::FrameRate::FPS_30, + duration: crate::time::Tick::from_seconds_f64(1.0), + ..Default::default() + }; + let mut old = new.clone(); + old.native_rate = Some(old.frame_rate); + old.frame_rate = crate::time::FrameRate::FPS_24; + old.duration = old.frame_rate.tick_of(30); + assert!(compatible(&old, &new), "conformed playback preserves native frame count"); + assert!(!compatible(&old, &Footage { duration: crate::time::Tick::from_seconds_f64(2.0), ..new.clone() })); + assert!(!compatible(&old, &Footage { width: 128, ..new })); + } + + #[cfg(unix)] + #[test] + fn symlink_cycles_and_external_files_are_skipped() { + let dir = Fixture::new(); + let (mut s, _, _) = setup(&dir, "plate.png"); + let outside = dir.file("outside/plate.png", b"valid"); + std::fs::create_dir(dir.0.join("new")).unwrap(); + std::os::unix::fs::symlink(dir.0.join("new"), dir.0.join("new/cycle")).unwrap(); + std::os::unix::fs::symlink(outside, dir.0.join("new/plate.png")).unwrap(); + assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound"); + } +} diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index ab9a785a..d167df9a 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -526,7 +526,12 @@ fn add(s: &mut Session, p: &Value) -> Result { } } apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?; - apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "renderQueue.add")?; + // "template" belongs to Render Settings on add, not Output Module. + let mut output_params = p.clone(); + if let Some(object) = output_params.as_object_mut() { + object.remove("template"); + } + apply_output(&mut it.output, &templates, s.state.region_of_interest, &output_params, "renderQueue.add")?; if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? { it.log = l; } @@ -583,6 +588,22 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let m = module_p(s, i, p, "renderQueue.setOutputModule")?; let mut om = module_ref(&s.project.render_queue[i], m).clone(); let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?; + + // When aspect is locked, an explicit height-only edit must drive the size. + // frame_size() derives height from width; calculate the matching width from the + // cropped render bounds before storing the updated module. + let width_given = + p.get("resizeWidth").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("width") || r.contains_key("preset")); + let height_given = p.get("resizeHeight").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("height")); + if om.resize.enabled && om.resize.lock_aspect && height_given && !width_given { + let item = &s.project.render_queue[i]; + let comp = s.project.comp(item.comp).ok_or(EngineError::NoComp)?; + let (w, h) = item.settings.output_size(comp); + let mut crop_only = om.clone(); + crop_only.resize.enabled = false; + let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); + } let post = match str_p(p, "postRenderAction") { Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?), None => None, diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index e6951c37..198c9bad 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -124,12 +124,38 @@ fn is_script(name: &str) -> bool { 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. +fn is_script_name(name: &str) -> bool { + is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name)) +} + +/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. +fn is_bare_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// Names that are not ordinary file names on Windows (elsewhere they are): with `:` (a drive or a +/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device +/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, +/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`). +fn windows_unsafe_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase(); + let port = |p: &str| { + let mut n = stem.strip_prefix(p).unwrap_or_default().chars(); + matches!((n.next(), n.next()), (Some('0'..='9' | '¹' | '²' | '³'), None)) + }; + let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || port("COM") || port("LPT"); + name.contains(':') || name.ends_with(['.', ' ']) || device +} + /// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name. 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)) { + 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" }); } } @@ -145,6 +171,9 @@ pub fn scripts(s: &Session) -> Vec { /// The code of an installed or bundled script / panel. pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool, String)> { + if !is_script(name) || !is_script_name(name) { + return None; + } if let Some(cfg) = &s.config { for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { if panel.is_some_and(|p| p != is_panel) { @@ -165,6 +194,10 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { if !is_script(&name) { return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)")); } + // A name it could not be run or uninstalled by (`\` is a file-name character on Unix). + if !is_script_name(&name) { + return Err(bad(c, format!("`{name}` is not a script file name"))); + } let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?; let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; @@ -186,6 +219,9 @@ fn list(s: &mut Session, _: &Value) -> Result { 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"))); + } let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { @@ -384,3 +420,168 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use serde_json::{Value, json}; + + use super::{BUNDLED, PANELS_DIR, SCRIPTS_DIR, is_bare_name, is_script, is_script_name, script_code, windows_unsafe_name}; + use crate::config::DirConfig; + use crate::{EngineError, Result, ScriptRequest, Session}; + + /// Stands in for the JavaScript runtime: reports the code it was asked to run. + fn echo(_: &mut Session, r: &ScriptRequest) -> Value { + json!({"ran": r.code}) + } + + /// A session whose settings store is a fresh `/settings/` (with both Scripts folders, as + /// after installing a script and a panel), next to a script `/outside.jsx`. + fn session_in_tmp(name: &str) -> (Session, PathBuf) { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let root = std::env::temp_dir().join(format!("ec-scripts-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("settings").join(PANELS_DIR)).unwrap(); + std::fs::write(root.join("outside.jsx"), "outside").unwrap(); + let s = Session { config: Some(Arc::new(DirConfig::new(root.join("settings")))), script: Some(echo), ..Default::default() }; + (s, root) + } + + fn bad_params(r: &Result) -> bool { + matches!(r, Err(EngineError::BadParams { .. })) + } + + /// Paths, refused on every platform. + const PATHS: [&str; 10] = ["", ".", "..", "../x.jsx", "a/b.jsx", "/abs/x.jsx", "a\\b.jsx", "..\\x.jsx", "C:\\x.jsx", "a\0.jsx"]; + /// Ordinary file names except on Windows. + const WINDOWS_ONLY: [&str; 8] = ["Step 1: Setup.jsx", "In:Out.jsx", "C:x.jsx", "x.jsx.", "x.jsx ", "Aux.Tools.jsx", "con.jsx", "COM1.jsx"]; + + #[test] + fn bare_names_are_one_path_component() { + for name in ["Rename Layers.jsx", "..jsx", "a..b.js", "Äpfel ✓.jsx"].into_iter().chain(WINDOWS_ONLY) { + assert!(is_bare_name(name), "{name:?}"); + } + for name in PATHS { + assert!(!is_bare_name(name), "{name:?}"); + } + } + + #[test] + fn windows_unsafe_names_are_drives_streams_trailing_dots_and_devices() { + let devices = ["CON", "Con.tar.jsx", "CON .jsx", "PRN.js", "aux.jsx", "NUL.jsx", "CONIN$.jsx", "conout$.js"]; + let ports = ["COM0.jsx", "com9.js", "LPT0.jsx", "lpt9.jsx", "COM¹.jsx", "LPT³.js"]; + for name in WINDOWS_ONLY.into_iter().chain(devices).chain(ports) { + assert!(windows_unsafe_name(name), "{name:?}"); + } + let fine = ["console.jsx", "Concert.jsx", "NULL.jsx", "CONIN.jsx", "COM10.jsx", "LPT.jsx", "x .jsx", "..jsx"]; + for name in BUNDLED.iter().map(|b| b.0).chain(fine) { + assert!(!windows_unsafe_name(name), "{name:?}"); + } + } + + #[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 { + assert!(!is_script_name(name), "{name:?}"); + } + for name in WINDOWS_ONLY { + assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}"); + } + // Install refuses a file name the script could not then be run or uninstalled by. + #[cfg(unix)] + { + let (mut s, root) = session_in_tmp("odd-name"); + let odd = root.join("a\\b.jsx"); + std::fs::write(&odd, "x").unwrap(); + assert!(bad_params(&s.execute("file.installScript", json!({"path": odd.to_string_lossy()})))); + assert!(!root.join("settings").join(SCRIPTS_DIR).join("a\\b.jsx").exists()); + 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"); + let (outside, prefs) = (root.join("outside.jsx"), root.join("settings").join("prefs.json")); + std::fs::write(&prefs, "{}").unwrap(); + // From `Scripts/`, `../../outside.jsx` is the script next to the settings folder and + // `../prefs.json` a settings file; an absolute path is not a script name either. + for name in ["../../outside.jsx".to_string(), "../prefs.json".into(), outside.to_string_lossy().into_owned()] { + let r = s.execute("file.uninstallScript", json!({"name": name})); + assert!(outside.is_file() && prefs.is_file(), "`{name}` deleted a file outside the Scripts folders: {r:?}"); + assert!(bad_params(&r), "`{name}`: {r:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn scripts_run_by_name_come_from_the_scripts_folders() { + let (mut s, root) = session_in_tmp("run"); + // `Scripts/../../outside.jsx` and `Scripts/ScriptUI Panels/../../../outside.jsx` are the + // script next to the settings folder. + let r = s.execute("file.runScript", json!({"name": "../../outside.jsx"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("window.scriptPanel", json!({"name": "../../../outside.jsx"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(script_code(&s, "../../outside.jsx", None), None); + assert_eq!(script_code(&s, "../../../outside.jsx", Some(true)), None); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn only_scripts_are_read_or_removed() { + // A settings file in the Scripts folder that is not a script (.jsx / .js). + let (mut s, root) = session_in_tmp("not-a-script"); + let notes = root.join("settings").join(SCRIPTS_DIR).join("notes.txt"); + std::fs::write(¬es, "notes").unwrap(); + assert_eq!(script_code(&s, "notes.txt", None), None); + let r = s.execute("file.uninstallScript", json!({"name": "notes.txt"})); + assert!(notes.is_file() && bad_params(&r), "{r:?}"); + let _ = std::fs::remove_dir_all(&root); + } + + /// On Unix `\` is a file-name character: a script dropped into the folder under such a name is + /// not offered, as it could not be run or uninstalled. Names only Windows refuses stay. + #[cfg(unix)] + #[test] + fn listing_skips_names_run_and_uninstall_refuse() { + let (mut s, root) = session_in_tmp("odd-names"); + let scripts = root.join("settings").join(SCRIPTS_DIR); + std::fs::write(scripts.join("a\\b.jsx"), "x").unwrap(); + std::fs::write(scripts.join("In:Out.jsx"), "in-out").unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let listed = |n: &str| list.as_array().unwrap().iter().any(|e| e["name"] == n); + assert!(!listed("a\\b.jsx") && listed("In:Out.jsx"), "{list}"); + assert_eq!(script_code(&s, "a\\b.jsx", None), None); + assert_eq!(script_code(&s, "In:Out.jsx", None), Some((false, "in-out".to_string()))); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn install_run_and_uninstall_by_name() { + let (mut s, root) = session_in_tmp("install"); + let file = root.join("Hello.jsx"); + std::fs::write(&file, "hello").unwrap(); + s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": file.to_string_lossy()})).unwrap(); + let installed = [root.join("settings").join(SCRIPTS_DIR).join("Hello.jsx"), root.join("settings").join(PANELS_DIR).join("Hello.jsx")]; + assert!(installed.iter().all(|p| p.is_file())); + assert_eq!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap()["ran"], "hello"); + let r = s.execute("window.scriptPanel", json!({"name": "Hello.jsx"})).unwrap(); + assert!(r["result"]["ran"].as_str().is_some_and(|c| c.contains("hello")), "{r}"); + // The bundled samples still run by name. + let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3; + assert_eq!(s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap()["ran"], sample); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert!(installed.iter().all(|p| !p.exists())); + assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index d02e3fbe..83ab0f01 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha Some(build::shape_group(ids, gname, items)) } -/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space). -pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { +/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry +/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's +/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit. +pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { let bez = match kind { "rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), @@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option< effectcraft_path::from_kurbo(&bez).into_iter().next() } +/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape +/// layer, else none (a new shape layer). +pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option { + s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)) +} + /// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer), /// else the first selected unlocked shape layer, else none (a new shape layer). pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result> { let target = match p.get("layer") { Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?), - None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)), + None => selected_shape_target(s, comp), }; if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) { return Err(bad(cmd, "the layer is not a shape layer")); diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs new file mode 100644 index 00000000..3414cc00 --- /dev/null +++ b/crates/engine/src/footage_reload.rs @@ -0,0 +1,272 @@ +//! Footage that changed on disk: File ▸ Reload Footage (Cmd+Alt+L) and Settings ▸ Import ▸ +//! Reload Footage Changed on Disk. +//! +//! Reloading re-reads what the file decides (its size, a movie's duration, streams and frame +//! rate, the codec, a data file's text) and keeps what the project decided: the Photoshop layer +//! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect, +//! fields, colour). A Photoshop layer is found again by name when layers were added or removed +//! around it. The media layer then forgets the file and the layer cache is emptied, so the next +//! render reads the new pixels. +//! +//! The automatic part compares each footage file's size and modification time with what they +//! were when the file was last seen. The desktop app takes those stamps every two seconds (and +//! when its window comes back to the front) on a worker thread, so a file on a slow network share +//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them +//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload +//! records the state of the disk: it is not an undo step and doesn't modify the project. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A file's size and modification time (see [`crate::Services::stamp`]). +pub type Stamp = (u64, u128); + +/// Stamps taken on a worker thread, waiting for [`poll_scan`]. +pub type Scan = Arc)>>>>; + +/// The files footage items read: each item's file and every frame of an image sequence. Image +/// sequences count only with Automatically Reload Footage set to All Footage (After Effects' +/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). +fn watched(s: &Session) -> BTreeSet { + let sequences = s.prefs.import.auto_reload_footage == "all"; + let mut paths = BTreeSet::new(); + for it in s.project.items.values() { + if let ItemKind::Footage(f) = &it.kind { + if !f.sequence.is_empty() && !sequences { + continue; + } + if !f.path.is_empty() { + paths.insert(f.path.clone()); + } + paths.extend(f.sequence.iter().cloned()); + } + } + paths +} + +/// Size and position in the stack of Photoshop layer `l` in the file at `path` now: the layer +/// at its recorded index when the name still matches, else the first layer with its name. +fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u32, u32)>)> { + let psd = effectcraft_psd::Psd::parse(s.services.read_file(path).ok()?).ok()?; + let found = psd.layers.get(l.index as usize).filter(|x| x.name == l.name).or_else(|| psd.layers.iter().find(|x| x.name == l.name))?; + let size = (!found.rect.is_empty()).then(|| (found.rect.width(), found.rect.height())); + Some((u32::try_from(found.index).ok()?, size)) +} + +/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). +fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { + let mut nf = f.clone(); + nf.missing = false; + // An image sequence picks up frames added to (or removed from) its run; its frame rate and + // order are the interpretation's. + if f.kind == FootageKind::Sequence { + if probed.kind == FootageKind::Sequence { + (nf.sequence, nf.width, nf.height) = (probed.sequence, probed.width, probed.height); + nf.sync_sequence_duration(); + } + return nf; + } + nf.codec = probed.codec; + nf.has_video = probed.has_video; + nf.has_audio = probed.has_audio; + if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { + nf.duration = probed.duration; + // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. + match f.native_rate { + Some(_) => nf.native_rate = Some(probed.native_rate.unwrap_or(probed.frame_rate)), + None => nf.frame_rate = probed.frame_rate, + } + } + if f.kind == FootageKind::Data { + nf.data = probed.data; + } + let (mut w, mut h) = (probed.width, probed.height); + match &f.layer { + // A smart object's size is its embedded file's, recorded at import. + Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(l) => match psd_layer(s, &f.path, l) { + Some((index, size)) => { + if let Some(nl) = nf.layer.as_mut() { + nl.index = index; + } + // Retain Layer Sizes: the layer's own bounds; otherwise the document's. + if l.layer_size { + (w, h) = size.unwrap_or((f.width, f.height)); + } + } + // The layer is gone from the file: keep the item as it was. + None => (w, h) = (f.width, f.height), + }, + // A later page of a PDF: the probe measures the first page. + None if f.page != 0 => (w, h) = (f.width, f.height), + None => {} + } + (nf.width, nf.height) = (w, h); + nf +} + +/// Reload footage items `ids` from their files (File ▸ Reload Footage): an undo step; items +/// whose file can't be read are marked missing. Returns how many items were reloaded. +pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result { + Ok(reload(s, ids, true)?.0) +} + +/// Reload items `ids`. `undoable` makes it an edit; otherwise it records the disk without +/// modifying the project, and an item whose file can't be read now (another app is still +/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read +/// (only without `undoable`). +fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet)> { + if s.importer.is_none() { + return Err(crate::EngineError::Other("media import is not available in this build".into())); + } + let mut updates = vec![]; + let mut paths = BTreeSet::new(); + let mut failed = BTreeSet::new(); + for i in ids { + if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) { + // Probed the way it imports by default, so a sequence is found as one. + let nf = match s.probe_footage(&f.path) { + Ok(p) => refreshed(s, f, p), + Err(_) if undoable => Footage { missing: true, ..f.clone() }, + Err(_) => { + failed.insert(f.path.clone()); + continue; + } + }; + paths.insert(f.path.clone()); + paths.extend(f.sequence.iter().chain(&nf.sequence).cloned()); + updates.push((*i, nf)); + } + } + let n = updates.len(); + if undoable { + s.edit("Reload Footage", None, |proj, _| { + for (i, f) in updates { + if let Some(it) = proj.item_mut(i) { + it.kind = ItemKind::Footage(f); + } + } + Ok(()) + })?; + } else { + let p = Arc::make_mut(&mut s.project); + let mut changed = false; + for (i, f) in updates { + if let Some(it) = p.item_mut(i) + && !matches!(&it.kind, ItemKind::Footage(cur) if *cur == f) + { + it.kind = ItemKind::Footage(f); + changed = true; + } + } + if changed { + let dirty = s.is_dirty(); + s.bump(); + if !dirty { + // Only the disk changed: the project isn't modified. + s.mark_saved(); + } + } + } + forget(s, &paths); + Ok((n, failed)) +} + +/// Make the media layer and the layer cache let go of `paths`, so renders read them again. +fn forget(s: &mut Session, paths: &BTreeSet) { + for p in paths { + s.footage.forget(p); + } + s.layer_cache.clear(); + s.events.push(crate::Event::PurgeCaches); +} + +/// Compare fresh stamps of the watched files with the ones last seen. A file seen for the first +/// time is only remembered; one whose stamp moved is reloaded: the items reading it get their +/// size and duration from it again and its pixels are read again. A file that can't be stamped +/// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be +/// read yet (still being written) keeps its old stamp so the next scan tries again; missing +/// files are left to the footage check. Returns `{files, reloaded}`. +pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate::Result { + let before = std::mem::take(&mut s.footage_stamps); + let mut changed = BTreeSet::new(); + // Only what is watched now is remembered (files no longer used are dropped). + for (path, stamp) in now { + match (before.get(&path), stamp) { + (Some(old), None) => { + s.footage_stamps.insert(path, *old); + } + (old, Some(stamp)) => { + if old.is_some_and(|o| *o != stamp) { + changed.insert(path.clone()); + } + s.footage_stamps.insert(path, stamp); + } + (None, None) => {} + } + } + if changed.is_empty() { + return Ok(json!({"files": 0, "reloaded": 0})); + } + let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect(); + let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? }; + for p in &failed { + if let Some(old) = before.get(p) { + s.footage_stamps.insert(p.clone(), *old); + } + changed.remove(p); + } + if changed.is_empty() { + return Ok(json!({"files": 0, "reloaded": 0})); + } + // Changed frames of image sequences (their items keep their metadata). + forget(s, &changed); + let files = changed.len(); + s.toast(if reloaded > 0 { + format!("Reloaded {reloaded} footage item(s) changed on disk") + } else { + format!("Reloaded {files} footage file(s) changed on disk") + }); + Ok(json!({"files": files, "reloaded": reloaded})) +} + +/// `footage.reloadChanged`: stamp the watched files now and reload the ones that changed. +pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { + let services = s.services.clone(); + let now = watched(s).into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); + apply_stamps(s, now) +} + +/// Start stamping the watched files on a worker thread, unless a scan is still running (or the +/// build has no threads: the web app, whose footage lives in browser storage). +pub fn start_scan(s: &mut Session) { + if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { + return; + } + let paths = watched(s); + if paths.is_empty() { + return; + } + let services = s.services.clone(); + let slot: Scan = Arc::default(); + let out = slot.clone(); + let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { + let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); + }); + if spawned.is_ok() { + s.footage_scan = Some(slot); + } +} + +/// Apply a finished scan (`None` while there is none, or it is still running). +pub fn poll_scan(s: &mut Session) -> Option> { + let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + s.footage_scan = None; + Some(apply_stamps(s, stamps)) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 24ccf1af..a9ee6b89 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod commands; pub mod config; pub mod demo; pub mod footage_check; +pub mod footage_reload; pub mod guard; pub mod history; pub mod jobs; @@ -101,6 +102,13 @@ pub trait Services: Send + Sync { fn exists(&self, path: &str) -> bool { std::path::Path::new(path).exists() } + /// A file's size and modification time (nanoseconds since 1970), to notice that it changed + /// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read. + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((meta.len(), modified.as_nanos())) + } /// Keep a file the app made for itself (footage extracted from a template): like /// [`Services::write_file`], creating its folder, but never offered as a download (web: it /// goes to browser storage only). @@ -459,6 +467,11 @@ pub struct Session { /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when /// they want it. pub check_footage_on_open: bool, + /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ + /// Reload Footage Changed on Disk, [`footage_reload`]). + pub footage_stamps: std::collections::HashMap, + /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). + pub footage_scan: Option, } /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). @@ -544,6 +557,8 @@ impl Default for Session { next_task_id: 1, learn: None, check_footage_on_open: false, + footage_stamps: Default::default(), + footage_scan: None, } } } @@ -1020,6 +1035,8 @@ mod tests_fidelity; #[cfg(test)] mod tests_frame_export; #[cfg(test)] +mod tests_key_metadata_order; +#[cfg(test)] mod tests_keylight; #[cfg(test)] mod tests_lottie; @@ -1165,3 +1182,6 @@ mod tests_paint; mod tests_panels; #[cfg(test)] mod tests_roto; + +#[cfg(test)] +mod tests_key_time; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c5830828..e98e29f6 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -553,6 +553,7 @@ File Find Missing Effects | file.findMissing {"what":"effects"} Find Missing Fonts | file.findMissing {"what":"fonts"} Find Missing Footage | file.findMissing {"what":"footage"} + Relink Missing Footage... | file.relinkFootage Watch Folder... | file.watchFolder --- Scripts diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc97..0bfa04a5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -23,8 +23,16 @@ pub const PREFS_VERSION: u32 = 2; pub const PREFS_FILE: &str = "prefs.json"; /// Settings ▸ General ▸ Language: (label, `general.language` value). -pub const LANGUAGES: &[(&str, &str)] = - &[("Match System", "system"), ("English", "en"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant"), ("Українська", "uk")]; +pub const LANGUAGES: &[(&str, &str)] = &[ + ("Match System", "system"), + ("English", "en"), + ("日本語", "ja"), + ("简体中文", "zh-hans"), + ("繁體中文", "zh-hant"), + ("Українська", "uk"), + ("Español", "es"), + ("Português (Brasil)", "pt-br"), +]; /// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent). pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")]; @@ -32,11 +40,28 @@ pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%" /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; +/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto +/// follows the operating system's light or dark appearance. +pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; + +/// The dark family of themes: (label, `appearance.darkTheme` value). +pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; + +/// The light family of themes: (label, `appearance.lightTheme` value). +pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; + +/// Every theme (the legacy single-theme `appearance.theme`). +pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; + /// The settings whose value must be one of their choices. fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { match key { "general.language" => Some(LANGUAGES), "startup.windowGraphics" => Some(WINDOW_GRAPHICS), + "appearance.appearanceMode" => Some(APPEARANCE_MODES), + "appearance.darkTheme" => Some(DARK_THEMES), + "appearance.lightTheme" => Some(LIGHT_THEMES), + "appearance.theme" => Some(THEMES), _ => None, } } @@ -140,8 +165,16 @@ page!(Previews { }); page!(Appearance { - /// `dark`, `darker` or `light`. + /// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older + /// settings files and automation: setting it picks its family's mode and theme. While the mode + /// is Dark or Light it mirrors the theme in use. theme: String = "dark".into(), + /// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark. + appearance_mode: String = "dark".into(), + /// The theme used in dark appearance: `dark` or `darker`. + dark_theme: String = "dark".into(), + /// The theme used in light appearance: `light`. + light_theme: String = "light".into(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, /// The size of the whole interface in percent (75–200), on top of the display's own scale. @@ -182,6 +215,10 @@ page!(Import { still_seconds: f64 = 5.0, sequence_fps: f64 = 30.0, report_missing_frames: bool = true, + /// Automatically Reload Footage whose file another app changed (size or modification time), + /// checked every two seconds and when the window comes back to the front: `off`, + /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. + auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. unlabeled_alpha: String = "ask".into(), /// Default drag import as: `footage`, `comp`, `compLayerSizes`. @@ -408,10 +445,61 @@ pub fn migrate(mut v: Value) -> Value { obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); } } + // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or + // Light mode, instead of switching established users to Auto. + if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) + && !a.contains_key("appearanceMode") + && let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string) + && let Some((mode, slot)) = theme_family(&theme) + { + a.insert("appearanceMode".into(), json!(mode)); + a.insert(slot.into(), json!(theme)); + } obj.insert("version".into(), json!(PREFS_VERSION)); v } +/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`, +/// `darkTheme`)); `None` for an unknown theme. +pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> { + if is_choice(DARK_THEMES, theme) { + Some(("dark", "darkTheme")) + } else if is_choice(LIGHT_THEMES, theme) { + Some(("light", "lightTheme")) + } else { + None + } +} + +impl Appearance { + /// Show the legacy single `theme`: fix the mode to its family and remember it as that + /// family's theme. An unknown theme changes nothing. + pub fn select_theme(&mut self) { + match theme_family(&self.theme) { + Some((mode, "darkTheme")) => { + self.appearance_mode = mode.into(); + self.dark_theme = self.theme.clone(); + } + Some((mode, _)) => { + self.appearance_mode = mode.into(); + self.light_theme = self.theme.clone(); + } + None => {} + } + } + + /// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light` + /// (`None` = the system said nothing: dark). + pub fn resolved_theme(&self, system_light: Option) -> &str { + let light = match self.appearance_mode.as_str() { + "light" => true, + "auto" => system_light == Some(true), + _ => false, + }; + if light { &self.light_theme } else { &self.dark_theme } + } +} + impl Prefs { /// Parse a settings document (migrating older layouts; unknown keys are kept, bad values /// fall back to defaults). @@ -459,6 +547,24 @@ impl Prefs { let a = &mut self.auto_save; a.interval_minutes = a.interval_minutes.clamp(1, 240); a.max_versions = a.max_versions.clamp(1, 99); + let ap = &mut self.appearance; + if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { + ap.appearance_mode = Appearance::default().appearance_mode; + } + if !is_choice(DARK_THEMES, &ap.dark_theme) { + ap.dark_theme = Appearance::default().dark_theme; + } + if !is_choice(LIGHT_THEMES, &ap.light_theme) { + ap.light_theme = Appearance::default().light_theme; + } + // The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the + // system, so it keeps the last one). + match ap.appearance_mode.as_str() { + "dark" => ap.theme = ap.dark_theme.clone(), + "light" => ap.theme = ap.light_theme.clone(), + _ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(), + _ => {} + } self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); let defaults = default_labels(); @@ -498,12 +604,20 @@ impl Prefs { let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); return Err(format!("`{key}` expects one of {}", all.join(", "))); } + // Older clients set the single theme (by key, or inside the page object): it still picks + // a visible theme, fixing the mode to its family. + let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; let ptr = format!("/{}", key.replace('.', "/")); let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?; let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?; *slot = value; let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; + let (a, b) = (&self.appearance, &next.appearance); + let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme; + if legacy_theme || only_theme_changed { + next.appearance.select_theme(); + } next.normalize(); *self = next; Ok(()) @@ -586,8 +700,18 @@ impl Prefs { /// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free, /// and halved while the system is low on memory when **Reduce Cache Size When System Is Low /// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets). + /// In the browser the layer and footage caches also stay within [`WEB_LAYER_CACHE`] and + /// [`WEB_MEDIA_CACHE`]. pub fn cache_budgets(&self, mem: Option) -> CacheBudgets { + self.cache_budgets_in(mem, cfg!(target_arch = "wasm32")) + } + + /// [`Prefs::cache_budgets`], in the browser's memory (`web`) or not. + pub(crate) fn cache_budgets_in(&self, mem: Option, web: bool) -> CacheBudgets { let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64); + if web { + (l, m) = (l.min(WEB_LAYER_CACHE), m.min(WEB_MEDIA_CACHE)); + } let floor = 64u64 << 20; let mut capped = false; let mut reduced = false; @@ -732,6 +856,15 @@ fn push_mru(list: &mut Vec, v: &str, max: usize) { list.truncate(max.max(1)); } +/// The most the layer cache holds in the browser. A wasm32 page addresses at most 4 GiB, which +/// these caches share with the app and with the decoders' own decoded frames (FilmCraft keeps +/// up to 384 MB per open movie): with the desktop's 1 GB defaults, eight stacked 1080p clips ran +/// the page out of memory a few seconds into playback. +pub const WEB_LAYER_CACHE: u64 = 512 << 20; + +/// The most the footage frame cache holds in the browser (see [`WEB_LAYER_CACHE`]). +pub const WEB_MEDIA_CACHE: u64 = 256 << 20; + /// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] pub struct CacheBudgets { @@ -1014,7 +1147,9 @@ pub fn pages() -> Vec { id: "appearance", title: "Appearance", items: vec![ - s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true), + s("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true), + s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true), + s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true), s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true), s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), Section("Labels and Colors"), @@ -1065,6 +1200,12 @@ pub fn pages() -> Vec { s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true), s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true), s("import.reportMissingFrames", "Report Missing Frames", B, true), + s( + "import.autoReloadFootage", + "Automatically Reload Footage", + Kind::Choice(&[("Off", "off"), ("Non-Sequence Footage", "nonSequence"), ("All Footage", "all")]), + true, + ), s( "import.unlabeledAlpha", "Interpret Unlabeled Alpha As", diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 60a73acf..fe376668 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -165,3 +165,43 @@ fn without_exporter() { assert_eq!(f["formats"].as_array().unwrap().len(), 12); assert_eq!(f["formats"][0]["available"], false); } + +#[test] +fn queue_add_accepts_render_settings_templates_without_output_module_namesakes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"C","width":64,"height":64,"duration":1})).unwrap(); + let all = s.execute("renderQueue.templates", json!({})).unwrap(); + let names = all["renderSettings"].as_array().unwrap(); + assert!(!names.is_empty()); + for row in names { + let name = row["name"].as_str().unwrap(); + let expected = s.project.render_templates.render_settings(name).unwrap(); + s.execute("renderQueue.add", json!({"comp":"C","template":name})).unwrap(); + assert_eq!(s.project.render_queue.last().unwrap().settings, expected); + } + let count = s.project.render_queue.len(); + assert!(s.execute("renderQueue.add", json!({"comp":"C","template":"Unknown Settings"})).is_err()); + assert_eq!(s.project.render_queue.len(), count); +} + +#[test] +fn locked_resize_uses_height_for_height_only_output_module_updates() { + for change in [json!({"resizeHeight": 32}), json!({"resize": {"height": 32, "lockAspect": true}})] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"frameRate":10,"duration":1})).unwrap(); + s.execute("renderQueue.add", json!({})).unwrap(); + s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 64, "lockAspect": true}})).unwrap(); + s.execute("renderQueue.setOutputModule", change).unwrap(); + let item = &s.project.render_queue[0]; + let comp = s.project.comp(item.comp).unwrap(); + assert_eq!(item.output.output_size(comp, &item.settings), (32, 32)); + assert_eq!(item.output.resize.height, 32); + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"duration":1})).unwrap(); + s.execute("renderQueue.add", json!({})).unwrap(); + s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 32, "height": 54, "lockAspect": true}})).unwrap(); + let item = &s.project.render_queue[0]; + let comp = s.project.comp(item.comp).unwrap(); + assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0fa0fd0f..587379a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -402,6 +402,26 @@ fn set_text_paragraph_fill_and_leading() { assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true)); } +#[test] +fn property_groups_report_actionable_errors() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T"})).unwrap(); + let l = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.group("text/pathOptions").unwrap().uid; + for selector in [json!({"path": "text"}), json!({"path": "text/pathOptions"}), json!({"path": format!("@{uid}")}), json!({"prop": uid})] { + let mut params = selector; + params["layer"] = json!(l); + for cmd in ["prop.get", "prop.set"] { + let err = s.execute(cmd, params.clone()).unwrap_err().to_string(); + assert!(err.contains("is a property group"), "{cmd}: {err}"); + assert!(err.contains("get_layer"), "{cmd}: {err}"); + } + } + assert!(s.execute("prop.get", json!({"layer": l, "path": "text/sourceText"})).is_ok()); + let err = s.execute("prop.get", json!({"layer": l, "path": "missing"})).unwrap_err().to_string(); + assert!(err.contains("no property `missing`"), "{err}"); +} + #[test] fn prop_get_and_render_rgba8() { let mut s = Session::default(); @@ -542,3 +562,18 @@ fn render_queue_add_accepts_documented_settings_when_checked() { assert!(r.is_ok(), "{r:?}"); assert!(s.execute_checked("renderQueue.add", json!({"bogus": 1})).is_err()); } + +#[test] +fn new_text_rejects_malformed_positions_without_creating_a_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); + for position in [json!([]), json!([10]), json!(["x", 20]), json!([null, 20])] { + let err = s.execute("layer.newText", json!({"text": "Hello", "position": position})).unwrap_err(); + assert!(err.to_string().contains("position: [x, y]"), "{err}"); + assert!(s.active_comp().unwrap().layers.is_empty()); + } + s.execute("layer.newText", json!({"name": "Valid", "text": "Hello", "position": [10, 20]})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 0.0])); + s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 2); +} diff --git a/crates/engine/src/tests_key_metadata_order.rs b/crates/engine/src/tests_key_metadata_order.rs new file mode 100644 index 00000000..fe2337c2 --- /dev/null +++ b/crates/engine/src/tests_key_metadata_order.rs @@ -0,0 +1,113 @@ +//! Key attributes preserve the order used by the next stagger gesture. + +use std::sync::Arc; + +use effectcraft_keyframe::Interp; +use effectcraft_project::LayerId; +use serde_json::json; + +use crate::{KeyRef, Session, time::FrameRate}; + +fn setup() -> (Session, u64) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + (s, layer) +} + +fn add(s: &mut Session, layer: u64, path: &str, time: i64, value: serde_json::Value) { + s.execute_checked("prop.addKey", json!({"layer": layer, "path": path, "time": time, "value": value})).unwrap(); +} + +fn uid(s: &Session, layer: u64, path: &str) -> u64 { + s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop(path).unwrap().uid +} + +fn key(layer: u64, prop: u64, frame: i64) -> KeyRef { + KeyRef { layer: LayerId(layer), prop, time: FrameRate::FPS_30.tick_of(frame) } +} + +#[test] +fn easing_preserves_interleaved_layer_and_property_order_with_one_undo() { + let (mut s, first) = setup(); + let second = s.execute_checked("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for layer in [first, second] { + for time in [0, 1] { + add(&mut s, layer, "transform/opacity", time, json!(time * 25)); + } + } + add(&mut s, first, "transform/position", 1, json!([10, 20])); + let a = uid(&s, first, "transform/opacity"); + let b = uid(&s, second, "transform/opacity"); + let position = uid(&s, first, "transform/position"); + s.execute_checked( + "keys.select", + json!({"keys": [ + {"layer": second, "prop": b, "time": 1}, + {"layer": first, "prop": position, "time": 1}, + {"layer": first, "prop": a, "time": 0}, + {"layer": second, "prop": b, "time": 0} + ]}), + ) + .unwrap(); + let order = s.state.selected_keys.clone(); + let before = s.project.clone(); + s.history.clear(); + s.execute_checked("keys.easyEase", json!({})).unwrap(); + assert_eq!(s.state.selected_keys, order); + assert_eq!(s.history.undo.len(), 1); + for selected in &order { + let keys = &s.active_comp().unwrap().layer(selected.layer).unwrap().props.find(selected.prop).unwrap().keys; + assert_eq!(keys.iter().find(|k| k.time == selected.time).unwrap().out_interp, Interp::Bezier); + } + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.project, &before)); + assert_eq!(s.state.selected_keys, order); +} + +#[test] +fn roving_keeps_the_selected_key_identity_and_arbitrary_order() { + let (mut s, layer) = setup(); + for (time, x) in [(0, 0), (1, 25), (2, 100)] { + add(&mut s, layer, "transform/position", time, json!([x, 0])); + } + let prop = uid(&s, layer, "transform/position"); + s.execute_checked( + "keys.select", + json!({"keys": [ + {"layer": layer, "prop": prop, "time": 1}, + {"layer": layer, "prop": prop, "time": 2}, + {"layer": layer, "prop": prop, "time": 0} + ]}), + ) + .unwrap(); + s.execute_checked("keys.interpolation", json!({"roving": true})).unwrap(); + assert_eq!(s.state.selected_keys, vec![key(layer, prop, 15), key(layer, prop, 60), key(layer, prop, 0)]); + let position = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap(); + assert_eq!(position.keys[1].value.as_vec2(), [25.0, 0.0]); + assert_eq!(position.keys[1].time, FrameRate::FPS_30.tick_of(15)); + assert!(position.keys[1].roving); + assert!(!position.keys[0].roving && !position.keys[2].roving); +} + +#[test] +fn attribute_edit_deduplicates_legacy_refs_and_drops_stale_refs_without_snapping() { + let (mut s, layer) = setup(); + for time in [0, 1] { + add(&mut s, layer, "transform/opacity", time, json!(20)); + } + let prop = uid(&s, layer, "transform/opacity"); + let first = key(layer, prop, 0); + let second = key(layer, prop, 30); + s.state.selected_keys = vec![second, key(layer, prop, 10), first, second, key(layer, u64::MAX, 0), first, key(u64::MAX, prop, 0)]; + s.history.clear(); + let result = s.execute_checked("keys.toggleHold", json!({})).unwrap(); + assert_eq!(result, json!(2)); + assert_eq!(s.state.selected_keys, vec![second, first]); + assert_eq!(s.history.undo.len(), 1); + let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap(); + assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Hold)); + s.execute_checked("edit.undo", json!({})).unwrap(); + let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap(); + assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Linear)); +} diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests_key_time.rs new file mode 100644 index 00000000..4d4ab367 --- /dev/null +++ b/crates/engine/src/tests_key_time.rs @@ -0,0 +1,149 @@ +//! Agent keyframe times on offset, stretched and reversed layers (#257). + +use effectcraft_project::LayerId; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::{Session, tests_timeline::prop}; + +fn offset_layer(start: f64, stretch: f64) -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Offset", "duration": 20, "frameRate": 30})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layer": layer, "start": start, "in": 10.5, "out": 20})).unwrap(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap().stretch = stretch; + (s, layer) +} + +fn assert_seconds(value: &Value, seconds: f64) { + assert!((value.as_f64().unwrap() - seconds).abs() < 1e-8, "{value} != {seconds}"); +} + +#[test] +fn comp_key_times_on_an_offset_layer_read_at_the_intended_time_and_undo() { + let (mut s, layer) = offset_layer(10.5, 100.0); + for (time, value) in [(15.3, 0), (16.0, 100)] { + let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": value})).unwrap(); + assert_seconds(&reply["compTime"], time); + assert_seconds(&reply["layerTime"], time - 10.5); + assert!(reply.get("warning").is_none()); + } + let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity", "time": 15.65})).unwrap(); + assert_seconds(&read["value"], 50.0); + assert_seconds(&read["keys"][0]["compTime"], 15.3); + let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.5, "timeBase": "comp", "value": 45})).unwrap(); + assert_seconds(&reply["compTime"], 15.5); + assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 2); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3); +} + +#[test] +fn default_layer_times_are_preserved_and_outside_keys_warn() { + let (mut s, layer) = offset_layer(10.5, 100.0); + let legacy = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 0})).unwrap(); + assert_eq!(legacy, json!(1), "ordinary legacy replies stay scalar"); + let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "value": 100})).unwrap(); + assert_eq!(outside, json!(2), "legacy replies keep their raw count outside the active range"); + let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity"})).unwrap(); + assert_seconds(&read["keys"][1]["time"], 15.3); + assert_seconds(&read["keys"][1]["compTime"], 25.8); + assert!(read["keys"][1].get("warning").is_some()); + let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 25.8}], "timeBase": "comp"})).unwrap(); + assert_seconds(&selected["keys"][0]["compTime"], 25.8); + assert!(selected.get("warning").is_some()); + let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap(); + assert_seconds(&moved["compTime"], 20.0); + assert!(moved.get("warning").is_some(), "out point is exclusive"); +} + +#[test] +fn stretched_and_reversed_keys_use_the_comp_frame_grid_for_both_bases() { + for (start, stretch) in [(10.5, 200.0), (18.0, -200.0)] { + let (mut s, layer) = offset_layer(start, stretch); + for time in [15.3, 16.0] { + let layer_time = (time - start) * 100.0 / stretch; + let comp_reply = + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": 20})).unwrap(); + assert_seconds(&comp_reply["layerTime"], layer_time); + let layer_reply = + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": layer_time, "timeBase": "layer", "value": 20})).unwrap(); + assert_seconds(&layer_reply["compTime"], time); + } + let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 15.3}], "timeBase": "comp"})).unwrap(); + assert_seconds(&selected["keys"][0]["compTime"], 15.3); + let moved = s + .execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 15.41, "timeBase": "comp", "value": 60})) + .unwrap(); + assert_seconds(&moved["compTime"], 15.4); + let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + assert_eq!(ly.comp_time(s.state.selected_keys[0].time), Tick::from_seconds_f64(15.4)); + let layer_reply = s.execute("keys.set", json!({"layer": layer, "path": "transform/rotation", "time": (15.3 - start) * 100.0 / stretch, "newTime": (15.41 - start) * 100.0 / stretch, "timeBase": "layer"})).unwrap(); + assert_seconds(&layer_reply["compTime"], 15.4); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let restored = prop(&s, layer, "transform/opacity"); + assert!(restored.keys.iter().any(|k| ly.comp_time(k.time) == Tick::from_seconds_f64(15.3))); + } +} + +#[test] +fn invalid_key_time_arguments_leave_properties_and_history_unchanged() { + let (mut s, layer) = offset_layer(10.5, 100.0); + let before = prop(&s, layer, "transform/opacity"); + let history_len = s.history.undo.len(); + for cmd in ["prop.addKey", "prop.set", "keys.set"] { + for extra in [json!({"time": "oops"}), json!({"time": 1e300}), json!({"timeBase": "seconds", "time": 0})] { + let mut p = json!({"layer": layer, "path": "transform/opacity", "value": 10}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(s.execute(cmd, p).is_err(), "{cmd}: {extra}"); + assert_eq!(prop(&s, layer, "transform/opacity"), before); + assert_eq!(s.history.undo.len(), history_len); + } + } + assert!(s.execute("keys.select", json!({"keys": [], "timeBase": null})).is_err()); +} + +#[test] +fn omitted_key_times_and_reads_use_the_target_comps_cti() { + let (mut s, layer) = offset_layer(10.5, 100.0); + let target = s.active_comp_id().unwrap().0; + s.execute("time.set", json!({"time": 15.3})).unwrap(); + s.execute("comp.new", json!({"name": "Other", "duration": 20})).unwrap(); + s.execute("time.set", json!({"time": 1})).unwrap(); + let reply = s.execute("prop.addKey", json!({"comp": target, "layer": layer, "path": "transform/opacity", "timeBase": "comp", "value": 25})).unwrap(); + assert_seconds(&reply["compTime"], 15.3); + let read = s.execute("prop.get", json!({"comp": target, "layer": layer, "path": "transform/opacity"})).unwrap(); + assert_seconds(&read["time"], 15.3); + assert_seconds(&read["value"], 25.0); +} + +#[test] +fn excessive_stretch_or_start_conversions_fail_without_clamping_keys() { + for (start, stretch, base, time) in [(10.5, 1e300, "layer", 1.0), (10.5, 1e-300, "comp", 15.3), (Tick::MAX.seconds(), 100.0, "layer", 1.0)] { + let (mut s, layer) = offset_layer(10.5, 100.0); + let cid = s.active_comp_id().unwrap(); + let ly = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap(); + ly.start_time = Tick::from_seconds_f64(start); + ly.stretch = stretch; + let before = s.history.undo.len(); + let result = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": base, "value": 10})); + assert!(result.is_err(), "start {start}, stretch {stretch}: {result:?}"); + assert_eq!(s.history.undo.len(), before); + assert!(prop(&s, layer, "transform/opacity").keys.is_empty()); + } +} + +#[test] +fn explicit_base_on_static_property_reports_timing_without_enabling_animation() { + let (mut s, layer) = offset_layer(10.5, 100.0); + let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 25})).unwrap(); + assert_seconds(&reply["compTime"], 15.3); + assert_eq!(reply["animated"], false); + assert_eq!(reply["value"], 25.0); + assert!(prop(&s, layer, "transform/opacity").keys.is_empty()); +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index a73527bb..b55442eb 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -202,6 +202,23 @@ fn cache_budgets_leave_reserved_ram_and_shrink_when_low() { assert!(!p.cache_budgets(Some(low)).reduced); } +/// Eight stacked 1080p clips ran the browser build out of its 4 GiB of memory: the footage and +/// layer caches kept their desktop sizes there, beside the decoders' own frames. +#[test] +fn web_cache_budgets_leave_room_for_the_decoders() { + use crate::prefs::{WEB_LAYER_CACHE, WEB_MEDIA_CACHE}; + let mut p = crate::prefs::Prefs::default(); + p.memory.layer_cache_mb = 4096; + p.memory.media_cache_mb = 4096; + let web = p.cache_budgets_in(None, true); + assert_eq!((web.layer as u64, web.media as u64), (WEB_LAYER_CACHE, WEB_MEDIA_CACHE)); + assert_eq!(web.preview, p.cache_budgets_in(None, false).preview, "the RAM preview keeps its budget"); + // Smaller settings stay as they are; the desktop is unchanged. + p.memory.media_cache_mb = 128; + assert_eq!(p.cache_budgets_in(None, true).media, 128 << 20); + assert_eq!(p.cache_budgets_in(None, false).layer, 4096 << 20); +} + #[test] fn motion_path_span_follows_the_composition_setting() { let mut p = crate::prefs::Prefs::default(); diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index ee7a3fca..1a445205 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -109,6 +109,60 @@ fn ui_scale_is_a_percentage_from_75_to_200() { assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100); } +/// Settings ▸ Appearance: new installs stay Dark (Sync with System is opt-in), and settings saved +/// with a single theme load as a fixed mode showing that theme. +#[test] +fn appearance_defaults_and_legacy_theme_migrate() { + let p = Prefs::default(); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); + assert_eq!(p.appearance.resolved_theme(Some(true)), "dark"); + for (saved, mode, dark, light) in [("darker", "dark", "darker", "light"), ("light", "light", "dark", "light"), ("dark", "dark", "dark", "light")] { + let p = Prefs::from_json(&json!({"version": 2, "appearance": {"theme": saved}}).to_string()); + assert_eq!(p.appearance.appearance_mode, mode, "{saved}"); + assert_eq!(p.appearance.dark_theme, dark, "{saved}"); + assert_eq!(p.appearance.light_theme, light, "{saved}"); + assert_eq!(p.appearance.theme, saved); + } + // The flat layout of the earliest builds migrates too. + assert_eq!(Prefs::from_json(r#"{"theme": "light"}"#).appearance.appearance_mode, "light"); + // A file that already has a mode keeps it; garbage falls back to the defaults. + let p = Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "dark", "appearanceMode": "auto", "darkTheme": "darker"}}"#); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str()), ("auto", "darker")); + let p = Prefs::from_json(r#"{"version": 2, "appearance": {"appearanceMode": "sepia", "darkTheme": "light", "lightTheme": 7}}"#); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); +} + +/// The appearance settings take only their choices, survive a restart, and old clients that set +/// the single theme still pick a visible theme without losing the other family's choice. +#[test] +fn appearance_choices_validate_and_legacy_theme_still_selects() { + let mut s = Session::default(); + s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "darker", "appearance.lightTheme": "light"}})) + .unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "auto"); + assert_eq!(s.prefs.appearance.dark_theme, "darker"); + assert_eq!(s.prefs.appearance.resolved_theme(Some(true)), "light"); + assert_eq!(s.prefs.appearance.resolved_theme(Some(false)), "darker"); + assert_eq!(s.prefs.appearance.resolved_theme(None), "darker", "no system answer: dark"); + assert_eq!(Prefs::from_json(&s.prefs.to_json()).appearance, s.prefs.appearance); + for (key, bad) in + [("appearance.darkTheme", "light"), ("appearance.lightTheme", "darker"), ("appearance.appearanceMode", "sepia"), ("appearance.theme", "neon")] + { + assert!(s.execute("prefs.set", json!({"key": key, "value": bad})).is_err(), "{key} = {bad}"); + } + s.execute("prefs.set", json!({"key": "appearance.theme", "value": "light"})).unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "light"); + assert_eq!(s.prefs.appearance.dark_theme, "darker"); + s.execute("prefs.set", json!({"key": "appearance", "value": {"theme": "dark"}})).unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "dark"); + assert_eq!(s.prefs.appearance.dark_theme, "dark"); + // A fixed mode mirrors its theme in the single-theme key for old readers. + s.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "light"})).unwrap(); + assert_eq!(s.prefs.get("appearance.theme"), Some(json!("light"))); + s.prefs.reset(Some("appearance")).unwrap(); + assert_eq!(s.prefs.appearance, crate::prefs::Appearance::default()); +} + #[test] fn every_schema_key_exists_and_docs_list_the_todo_settings() { let p = Prefs::default(); @@ -530,6 +584,10 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { s.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("ja")); + // pt-br is registered alongside en and ja, and round-trips through the file like any other. + s.execute("prefs.set", json!({"key": "general.language", "value": "pt-br"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br")); + s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let saved = store.read(PREFS_FILE).unwrap(); for bad in [json!("fr"), json!(""), json!(17), json!(null)] { assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err()); @@ -552,6 +610,11 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { uk.load_settings(); assert_eq!(uk.prefs.general.language, "uk"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"uk"}}"#).general.language, "uk"); + // es is registered the same way. + s.execute("prefs.set", json!({"key": "general.language", "value": "es"})).unwrap(); + let mut es = Session { config: Some(store.clone()), ..Default::default() }; + es.load_settings(); + assert_eq!(es.prefs.general.language, "es"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index d543fc9a..ef87bf86 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -259,3 +259,19 @@ fn fonts_lists_bundled_and_installed_families() { assert_eq!(crate::font_styles("NOTO SERIF"), styles); assert_eq!(crate::font_styles("No Such Family").len(), 5); } + +/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control +/// channel, MCP) accepts them and requires none (#520). +#[test] +fn set_text_schema_accepts_every_opentype_key_and_requires_none() { + let spec = crate::commands::find("layer.setText").unwrap(); + let schema = crate::commands::params_schema(spec); + assert_eq!(schema["required"], json!([])); + for key in ["discretionaryLigatures", "ss01", "ss02", "ss20", "swash"] { + assert!(schema["properties"].get(key).is_some(), "{key} is not in the schema"); + } + assert!(schema["properties"].get("OpenType").is_none()); + let (mut s, t) = session("Office 1/2"); + s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap(); + assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20)); +} diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index cdfbf8dd..de746f66 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -322,6 +322,22 @@ fn alt_click_stopwatch_toggles_a_self_reference_expression() { assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled); } +#[test] +fn add_expression_without_path_uses_the_selected_property() { + let (mut s, l) = setup(); + // Nothing selected: the same hint as Add Property to Essential Graphics. + let err = s.execute("prop.setExpression", json!({})).unwrap_err().to_string(); + assert!(err.contains("select a property in the timeline"), "{err}"); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); + // Animation > Add Expression sends no parameters. + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + s.execute("prop.setExpression", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity"); + // One undo step removes it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); +} + #[test] fn layer_comp_and_key_times_stay_frame_aligned() { let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t; diff --git a/crates/engine/tests/key_time_post_snap_bounds.rs b/crates/engine/tests/key_time_post_snap_bounds.rs new file mode 100644 index 00000000..f7d547fc --- /dev/null +++ b/crates/engine/tests/key_time_post_snap_bounds.rs @@ -0,0 +1,77 @@ +//! A checked comp time must remain supported after nearest-frame rounding. +use effectcraft_engine::Session; +use effectcraft_project::LayerId; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +fn project(s: &Session) -> Value { + serde_json::to_value(s.project.as_ref()).unwrap() +} + +fn stored_comp_time(s: &Session, layer: u64, value: f64) -> Tick { + let owner = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let property = owner.props.prop("transform/opacity").unwrap(); + let key = property.keys.iter().find(|key| key.value.to_json().as_f64() == Some(value)).unwrap(); + owner.comp_time(key.time) +} + +fn check_bound(upper: bool) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Post-snap bound", "width": 80, "height": 60, "duration": 20, "frameRate": 25})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let start = if upper { 10.0 } else { -10.0 }; + s.execute("layer.timing", json!({"layer": layer, "start": start})).unwrap(); + let owner = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + assert_eq!(owner.start_time, Tick::from_seconds_f64(start), "ordinary frame-aligned start"); + assert_eq!(owner.stretch, 100.0); + + let bound = if upper { Tick::MAX } else { Tick::MIN }; + let nearby = if upper { bound - Tick(TICKS_PER_SECOND) } else { bound + Tick(TICKS_PER_SECOND) }; + let control_history = s.history.undo.len(); + let control = + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": nearby.seconds(), "timeBase": "comp", "value": 25})).unwrap(); + assert_eq!(control["count"].as_u64(), Some(1), "nearby in-range key succeeds"); + let actual_control = stored_comp_time(&s, layer, 25.0); + assert!((Tick::MIN..=Tick::MAX).contains(&actual_control), "actual nearby comp time remains supported"); + assert!( + actual_control.0.abs_diff(nearby.0) < (TICKS_PER_SECOND / 25) as u64, + "nearby control remains within one ordinary 25fps frame of its requested time" + ); + assert_eq!(s.history.undo.len(), control_history + 1, "control records one edit"); + eprintln!("BOUND_NEARBY_CONTROL upper={upper} requested={} actual={} reply={control}", nearby.0, actual_control.0); + + let before = project(&s); + let history_before = s.history.undo.len(); + let redo_before = s.history.redo.len(); + let outcome = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": bound.seconds(), "timeBase": "comp", "value": 75})); + if let Ok(reply) = &outcome { + assert_eq!(reply["count"].as_u64(), Some(2), "the accepted boundary call added its key"); + let actual = stored_comp_time(&s, layer, 75.0); + let exceeds = if upper { actual > Tick::MAX } else { actual < Tick::MIN }; + assert!(exceeds, "an accepted boundary call must prove the predicted outward comp-time snap"); + assert_eq!(s.history.undo.len(), history_before + 1, "the accepted boundary call is an actual edit"); + eprintln!( + "BOUND_OUTWARD_ACCEPTED upper={upper} requested={} actual={} history_before={} history_after={} reply={reply}", + bound.0, + actual.0, + history_before, + s.history.undo.len() + ); + } + assert!(outcome.is_err(), "nearest-frame rounding beyond the supported comp-time bound must reject before an edit: upper={upper}, outcome={outcome:?}"); + let error = outcome.unwrap_err().to_string(); + assert!(error.contains("converts outside the supported time range"), "contextual conversion error: {error}"); + assert_eq!(project(&s), before, "rejected post-snap time leaves the full project unchanged"); + assert_eq!(s.history.undo.len(), history_before, "rejected post-snap time records no Undo step"); + assert_eq!(s.history.redo.len(), redo_before, "rejected post-snap time leaves Redo unchanged"); +} + +#[test] +fn upper_supported_comp_time_is_rejected_after_outward_frame_snap() { + check_bound(true); +} + +#[test] +fn lower_supported_comp_time_is_rejected_after_outward_frame_snap() { + check_bound(false); +} diff --git a/crates/engine/tests/key_time_reply_contract.rs b/crates/engine/tests/key_time_reply_contract.rs new file mode 100644 index 00000000..001b3789 --- /dev/null +++ b/crates/engine/tests/key_time_reply_contract.rs @@ -0,0 +1,201 @@ +//! Omitted time bases retain existing result shapes even for valid keys outside [in, out). +use effectcraft_engine::Session; +use effectcraft_project::LayerId; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +const LAYER_TIME: f64 = 15.3; +const COMP_TIME: f64 = 25.8; + +fn setup(text: bool) -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Reply contract", "width": 80, "height": 60, "duration": 20, "frameRate": 30})).unwrap(); + let created = if text { + s.execute("layer.newText", json!({"name": "Document", "text": "Before"})).unwrap() + } else { + s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap() + }; + let layer = created["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layer": layer, "start": 10.5, "in": 10.5, "out": 20})).unwrap(); + (s, layer) +} + +fn project(s: &Session) -> Value { + serde_json::to_value(s.project.as_ref()).unwrap() +} + +fn read(s: &mut Session, layer: u64, path: &str, time: f64) -> Value { + s.execute("prop.get", json!({"layer": layer, "path": path, "time": time})).unwrap() +} + +fn outside_key(s: &Session, layer: u64, path: &str, layer_time: f64, comp_time: f64) { + let comp = s.active_comp().unwrap(); + let owner = comp.layer(LayerId(layer)).unwrap(); + let property = owner.props.prop(path).unwrap(); + let at = Tick::from_seconds_f64(layer_time); + assert!(property.keys.iter().any(|key| key.time == at), "actual stored layer-time key"); + assert_eq!(owner.comp_time(at), Tick::from_seconds_f64(comp_time), "actual comp-time mapping"); + assert!(!owner.is_active_at(owner.comp_time(at)), "authored key is outside the active layer range"); +} + +fn undo_redo(s: &mut Session, before: &Value, after: &Value, history_before: usize) { + assert_ne!(before, after, "the public edit changed the project"); + assert_eq!(s.history.undo.len(), history_before + 1, "one public edit or batch Undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(project(s), *before, "complete project Undo"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(project(s), *after, "complete project Redo"); +} + +fn seconds(actual: &Value, expected: f64) { + assert!((actual.as_f64().unwrap() - expected).abs() < 1e-8, "{actual} != {expected}"); +} + +#[test] +fn legacy_outside_add_key_keeps_its_numeric_count_and_undo() { + let (mut s, layer) = setup(false); + let positive = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap(); + assert_eq!(positive.as_u64(), Some(1), "ordinary in-range numeric reply positive"); + let before = project(&s); + let history_before = s.history.undo.len(); + let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap(); + outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); + eprintln!("LEGACY_REPLY addKey outside: {reply}"); + assert_eq!(reply.as_u64(), Some(2), "omitted timeBase must preserve the numeric addKey count outside the active range"); +} + +#[test] +fn legacy_outside_scalar_set_keeps_its_value_and_undo() { + let (mut s, layer) = setup(false); + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap(); + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap(); + let before = project(&s); + let history_before = s.history.undo.len(); + let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 40})).unwrap(); + outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME); + seconds(&read(&mut s, layer, "transform/opacity", COMP_TIME)["value"], 40.0); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); + eprintln!("LEGACY_REPLY scalar set outside: {reply}"); + assert_eq!(reply.as_f64(), Some(40.0), "omitted timeBase must preserve the scalar set value outside the active range"); +} + +#[test] +fn legacy_outside_selection_keeps_its_numeric_count_without_an_edit() { + let (mut s, layer) = setup(false); + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap(); + outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME); + let before = project(&s); + let history_before = s.history.undo.len(); + let reply = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": LAYER_TIME}]})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 1, "actual owner selected"); + assert_eq!(s.state.selected_keys[0].layer, LayerId(layer)); + assert_eq!(s.state.selected_keys[0].time, Tick::from_seconds_f64(LAYER_TIME)); + assert_eq!(project(&s), before); + assert_eq!(s.history.undo.len(), history_before); + eprintln!("LEGACY_REPLY select outside: {reply}"); + assert_eq!(reply.as_u64(), Some(1), "omitted timeBase must preserve the numeric selection count outside the active range"); +} + +#[test] +fn legacy_outside_position_set_keeps_its_array_value_and_undo() { + let (mut s, layer) = setup(false); + s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [10, 20, 0]})).unwrap(); + let before = project(&s); + let history_before = s.history.undo.len(); + let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [12, 34, 0]})).unwrap(); + outside_key(&s, layer, "transform/position", LAYER_TIME, COMP_TIME); + let actual = read(&mut s, layer, "transform/position", COMP_TIME)["value"].clone(); + let values = actual.as_array().unwrap(); + assert_eq!(values.len(), 3, "ordinary Position value remains a vector"); + seconds(&values[0], 12.0); + seconds(&values[1], 34.0); + seconds(&values[2], 0.0); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); + eprintln!("LEGACY_REPLY array set outside: {reply}"); + assert_eq!(reply, actual, "omitted timeBase must return the raw array value rather than a timing wrapper"); +} + +#[test] +fn legacy_outside_text_set_keeps_its_string_value_and_undo() { + let (mut s, layer) = setup(true); + let source = read(&mut s, layer, "text/sourceText", 10.5)["value"].clone(); + assert_eq!(source.as_str(), Some("Before"), "public Source Text returns its text string"); + s.execute("prop.addKey", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": source})).unwrap(); + let before = project(&s); + let history_before = s.history.undo.len(); + let reply = s.execute("prop.set", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": "Outside final"})).unwrap(); + outside_key(&s, layer, "text/sourceText", LAYER_TIME, COMP_TIME); + let actual = read(&mut s, layer, "text/sourceText", COMP_TIME)["value"].clone(); + assert_eq!(actual.as_str(), Some("Outside final"), "actual Source Text edit"); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); + eprintln!("LEGACY_REPLY text set outside: {reply}"); + assert_eq!(reply, actual, "omitted timeBase must return the raw Source Text string unchanged"); +} + +#[test] +fn legacy_outside_count_remains_usable_by_the_documented_batch_reference() { + let (mut s, layer) = setup(false); + let before = project(&s); + let history_before = s.history.undo.len(); + let outcome = s.execute( + "engine.batch", + json!({"label": "Use key count", "steps": [ + {"command": "prop.addKey", "params": {"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60}}, + {"command": "prop.set", "params": {"layer": layer, "path": "transform/rotation", "value": "$1"}} + ]}), + ); + eprintln!("LEGACY_REPLY batch count substitution: {outcome:?}"); + assert!(outcome.is_ok(), "a valid outside addKey count must remain usable as the documented $1 numeric value: {outcome:?}"); + let reply = outcome.unwrap(); + assert_eq!(reply["steps"].as_u64(), Some(2)); + assert_eq!(reply["results"][0].as_u64(), Some(1)); + assert_eq!(reply["results"][1].as_f64(), Some(1.0)); + seconds(&read(&mut s, layer, "transform/rotation", 10.5)["value"], 1.0); + outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); +} + +#[test] +fn explicit_time_bases_keep_timing_and_existing_object_warnings() { + let (mut s, layer) = setup(false); + let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "timeBase": "layer", "value": 60})).unwrap(); + assert_eq!(outside["count"].as_u64(), Some(1)); + seconds(&outside["layerTime"], LAYER_TIME); + seconds(&outside["compTime"], COMP_TIME); + assert!(outside["warning"].as_str().unwrap().contains("outside")); + let inside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 20})).unwrap(); + seconds(&inside["layerTime"], 4.8); + seconds(&inside["compTime"], 15.3); + assert_eq!(inside["count"].as_u64(), Some(2)); + assert!(inside.get("warning").is_none()); + let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": COMP_TIME}], "timeBase": "comp"})).unwrap(); + assert_eq!(selected["count"].as_u64(), Some(1)); + seconds(&selected["keys"][0]["compTime"], COMP_TIME); + assert!(selected["warning"].as_str().unwrap().contains("outside")); + let changed = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": COMP_TIME, "timeBase": "comp", "value": 40})).unwrap(); + seconds(&changed["value"], 40.0); + seconds(&changed["compTime"], COMP_TIME); + assert!(changed["warning"].as_str().unwrap().contains("outside")); + let info = read(&mut s, layer, "transform/opacity", COMP_TIME); + let keys = info["keys"].as_array().unwrap(); + let key = keys.iter().find(|key| (key["time"].as_f64().unwrap() - LAYER_TIME).abs() < 1e-8).unwrap(); + seconds(&key["layerTime"], LAYER_TIME); + seconds(&key["compTime"], COMP_TIME); + assert!(key["warning"].as_str().unwrap().contains("outside"), "prop.get already has an object response"); + let before = project(&s); + let history_before = s.history.undo.len(); + let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "newTime": 15.4})).unwrap(); + assert!(moved.is_object(), "keys.set already returned an object without timeBase"); + seconds(&moved["layerTime"], 15.4); + seconds(&moved["compTime"], 25.9); + assert!(moved["warning"].as_str().unwrap().contains("outside")); + outside_key(&s, layer, "transform/opacity", 15.4, 25.9); + let after = project(&s); + undo_redo(&mut s, &before, &after, history_before); +} diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs new file mode 100644 index 00000000..8c06cf8a --- /dev/null +++ b/crates/engine/tests/parenting_3d_position.rs @@ -0,0 +1,464 @@ +//! 3D parenting preserves Position and static poses in uniform parent spaces. +use effectcraft_engine::{ + Session, + geom::{Mat4, Vec3, vec3}, + project::{BitDepth, LayerId}, + raster::Image, + render::{Backend, EvalCtx, NoFootage, RenderOpts, Renderer}, + time::Tick, +}; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn command(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, p).unwrap() +} +fn set(s: &mut Session, layer: u64, path: &str, value: Value) { + command(s, "prop.set", json!({"layer":layer,"path":path,"value":value})); +} +fn solid(s: &mut Session, name: &str) -> u64 { + command(s, "layer.newSolid", json!({"name":name,"width":12,"height":10,"color":[0.8,0.3,0.1]}))["layer"].as_u64().unwrap() +} +fn fixture(three: bool, rotated: bool) -> (Session, u64, u64) { + let mut s = Session::default(); + command(&mut s, "comp.new", json!({"name":"Synthetic 3D parent","width":64,"height":48,"duration":2,"frameRate":24})); + Arc::make_mut(&mut s.project).settings.bit_depth = BitDepth::Bpc32; + let parent = solid(&mut s, "Parent"); + let child = solid(&mut s, "Child"); + command(&mut s, "layer.setSwitch", json!({"layers":[parent],"switch":"video","value":false})); + if three { + command(&mut s, "layer.setSwitch", json!({"layers":[parent,child],"switch":"threeD","value":true})); + } + set(&mut s, parent, "transform/position", json!([20.0, 14.0, if three { 8.0 } else { 0.0 }])); + set(&mut s, child, "transform/position", json!([30.0, 25.0, if three { 3.0 } else { 0.0 }])); + if rotated { + set(&mut s, parent, "transform/orientation", json!([15.0, -18.0, 23.0])); + set(&mut s, parent, "transform/rotationX", json!(7.0)); + set(&mut s, parent, "transform/rotationY", json!(-11.0)); + set(&mut s, parent, "transform/rotation", json!(9.0)); + set(&mut s, parent, "transform/scale", json!([120.0, 120.0, 120.0])); + set(&mut s, child, "transform/orientation", json!([-8.0, 12.0, -13.0])); + set(&mut s, child, "transform/rotationX", json!(4.0)); + set(&mut s, child, "transform/rotationY", json!(6.0)); + set(&mut s, child, "transform/rotation", json!(-5.0)); + } + (s, parent, child) +} +fn world(s: &Session, child: u64, t: Tick) -> Mat4 { + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap(); + EvalCtx::new(&s.project, cid, c, t).world_matrix(c.layer(LayerId(child)).unwrap()) +} +fn points(m: Mat4) -> [Vec3; 5] { + [vec3(6.0, 5.0, 0.0), vec3(0.0, 0.0, 0.0), vec3(12.0, 0.0, 0.0), vec3(0.0, 10.0, 0.0), vec3(12.0, 10.0, 0.0)].map(|p| m.apply(p)) +} +fn displacement(a: Mat4, b: Mat4) -> f64 { + points(a).into_iter().zip(points(b)).map(|(a, b)| (a - b).length()).fold(0.0, f64::max) +} +fn cpu(s: &Session, t: Tick) -> Image { + Renderer::new(&s.project, &NoFootage, RenderOpts { backend: Backend::Cpu, motion_blur: false, ..Default::default() }) + .comp_frame_cpu(s.active_comp_id().unwrap(), t) +} +fn finite_visible(i: &Image) { + assert_eq!([i.width, i.height], [64, 48]); + assert!(i.data.iter().flatten().all(|v| v.is_finite())); + assert!(i.data.iter().any(|p| p[3] > 0.9 && p[0] > 0.2), "synthetic child must be visibly rendered"); +} +fn diff(a: &Image, b: &Image) -> f32 { + assert_eq!([a.width, a.height], [b.width, b.height]); + a.data.iter().flatten().zip(b.data.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max) +} +fn parent(s: &mut Session, child: u64, to: Option, compensate: bool) { + command(s, "layer.setParent", json!({"layers":[child],"parent":to,"compensate":compensate})); +} +#[test] +fn parenting_2d_compensation_positive_control() { + let (mut s, p, c) = fixture(false, false); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + let old = s.project.clone(); + let undo = s.history.undo.len(); + parent(&mut s, c, Some(p), true); + assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9); + assert!(diff(&image, &cpu(&s, Tick::ZERO)) < 1e-6); + assert_eq!(s.history.undo.len(), undo + 1); + let attached = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&old, &s.project)); + assert!(s.redo()); + assert!(Arc::ptr_eq(&attached, &s.project)); + parent(&mut s, c, None, true); + assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9); +} +#[test] +fn parenting_3d_explicit_uncompensated_positive_control() { + let (mut s, p, c) = fixture(true, true); + let old = world(&s, c, Tick::ZERO); + let pm = world(&s, p, Tick::ZERO); + let old_transform = s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap().clone(); + parent(&mut s, c, Some(p), false); + let after = world(&s, c, Tick::ZERO); + assert!(displacement(pm * old, after) < 1e-9); + assert!(displacement(old, after) > 1.0, "control must visibly follow its parent"); + assert_eq!(&old_transform, s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap()); + assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite())); +} +#[test] +fn parenting_3d_default_parent_preserves_position_and_cpu_pixels() { + let (mut s, p, c) = fixture(true, false); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + // Omit compensate to exercise the same public default as timeline pick-whip. + command(&mut s, "layer.setParent", json!({"layers":[c],"parent":p})); + let out = cpu(&s, Tick::ZERO); + assert!(out.data.iter().flatten().all(|v| v.is_finite())); + let motion = displacement(before, world(&s, c, Tick::ZERO)); + let pixels = diff(&image, &out); + eprintln!("DEFAULT_PARENT motion={motion} pixels={pixels}"); + assert!(motion < 1e-9 && pixels < 1e-6, "3D default parenting must preserve world position and rendered pixels"); +} +#[test] +fn parenting_3d_rotated_uniform_parent_preserves_pose() { + let (mut s, p, c) = fixture(true, true); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + parent(&mut s, c, Some(p), true); + let motion = displacement(before, world(&s, c, Tick::ZERO)); + let out = cpu(&s, Tick::ZERO); + let pixels = diff(&image, &out); + eprintln!("ROTATED_PARENT motion={motion} pixels={pixels}"); + assert!(motion < 1e-8 && pixels < 2e-5, "3D rotated uniform parent must preserve child pose"); +} +#[test] +fn parenting_3d_unparent_preserves_position() { + let (mut s, p, c) = fixture(true, false); + parent(&mut s, c, Some(p), false); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + parent(&mut s, c, None, true); + let motion = displacement(before, world(&s, c, Tick::ZERO)); + let pixels = diff(&image, &cpu(&s, Tick::ZERO)); + eprintln!("UNPARENT motion={motion} pixels={pixels}"); + assert!(motion < 1e-9 && pixels < 1e-6, "3D unparenting must preserve world position"); +} +#[test] +fn parenting_3d_animated_position_preserves_authored_keys() { + let (mut s, p, c) = fixture(true, false); + for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let undo = s.history.undo.len(); + let old = s.project.clone(); + parent(&mut s, c, Some(p), true); + let motion = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + eprintln!("ANIMATED_PARENT motion={motion}"); + assert!(motion < 1e-8, "3D parenting must re-express ordinary Position keys"); + assert_eq!(s.history.undo.len(), undo + 1); + let attached = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&old, &s.project)); + assert!(s.redo()); + assert!(Arc::ptr_eq(&attached, &s.project)); +} + +fn anchor_motion(a: Mat4, b: Mat4) -> f64 { + (a.apply(vec3(6.0, 5.0, 0.0)) - b.apply(vec3(6.0, 5.0, 0.0))).length() +} +#[test] +fn parenting_3d_rotated_parent_preserves_anchor() { + let (mut s, p, c) = fixture(true, true); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + parent(&mut s, c, Some(p), true); + let after = world(&s, c, Tick::ZERO); + let anchor = anchor_motion(before, after); + eprintln!("ROTATED_ANCHOR anchor={anchor} corners={} pixels={}", displacement(before, after), diff(&image, &cpu(&s, Tick::ZERO))); + assert!(anchor < 1e-8, "3D rotated parenting must retain the world-space anchor"); + parent(&mut s, c, None, true); + assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "3D rotated unparent must retain the world-space anchor"); +} +#[test] +fn parenting_3d_separated_position_preserves_each_axis_and_undo() { + let (mut s, p, c) = fixture(true, false); + command(&mut s, "prop.separateDimensions", json!({"layer":c,"value":true})); + for (name, keys) in [ + ("positionX", vec![(0.0, 27.0), (1.0, 35.0)]), + ("positionY", vec![(0.0, 22.0), (0.5, 27.0), (1.0, 29.0)]), + ("positionZ", vec![(0.0, 2.0), (0.25, 3.0), (1.0, 5.0)]), + ] { + for (time, value) in keys { + command(&mut s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value})); + } + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let old = s.project.clone(); + let undo = s.history.undo.len(); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + eprintln!("SEPARATED_PARENT motion={error}"); + assert!(error < 1e-8, "3D parenting must preserve separated Position animation"); + for name in ["positionX", "positionY", "positionZ"] { + let a = &old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys; + let b = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys; + assert_eq!(a.iter().map(|k| k.time).collect::>(), b.iter().map(|k| k.time).collect::>(), "parent compensation does not retime axes"); + } + assert_eq!(s.history.undo.len(), undo + 1); + let attached = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&old, &s.project)); + assert!(s.redo()); + assert!(Arc::ptr_eq(&attached, &s.project)); + parent(&mut s, c, None, true); + for (t, b) in times.into_iter().zip(before) { + assert!(displacement(b, world(&s, c, t)) < 1e-8); + } +} +#[test] +fn parenting_3d_position_spatial_tangents_follow_relative_parent_space() { + let (mut s, p, c) = fixture(true, true); + for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); + } + // Generated manual spatial-curve data; temporal interpolation remains linear. + let cid = s.active_comp_id().unwrap(); + let pr = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap(); + for key in &mut pr.keys { + key.spatial_auto = false; + } + pr.keys[0].spatial_out = [3.0, 5.0, -1.0]; + pr.keys[1].spatial_in = [-4.0, 2.0, 1.0]; + let tangents = pr.keys.iter().map(|k| (k.spatial_in, k.spatial_out)).collect::>(); + let relative = world(&s, p, Tick::ZERO).inverse().unwrap(); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| anchor_motion(b, world(&s, c, t))).fold(0.0, f64::max); + let keys = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/position").unwrap().keys; + let tangent_error = keys + .iter() + .zip(tangents) + .flat_map(|(key, (tin, tout))| [(key.spatial_in, tin), (key.spatial_out, tout)]) + .map(|(after, before)| (Vec3::from(after) - relative.apply_vec(Vec3::from(before))).length()) + .fold(0.0, f64::max); + eprintln!("SPATIAL_PARENT anchor={error} tangent={tangent_error}"); + assert!(error < 1e-8 && tangent_error < 1e-8, "3D Position spatial tangents must remain relative vectors"); +} +#[test] +fn parenting_3d_parent_source_pixel_aspect_is_not_inherited() { + let (mut s, _, c) = fixture(true, false); + let p = command(&mut s, "layer.newSolid", json!({"name":"Wide pixels parent","width":12,"height":10,"color":[0.1,0.2,0.3],"pixelAspect":2.0}))["layer"] + .as_u64() + .unwrap(); + command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"threeD","value":true})); + command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"video","value":false})); + set(&mut s, p, "transform/position", json!([20.0, 14.0, 8.0])); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + finite_visible(&image); + parent(&mut s, c, Some(p), true); + let error = displacement(before, world(&s, c, Tick::ZERO)); + let pixels = diff(&image, &cpu(&s, Tick::ZERO)); + eprintln!("PAR_PARENT motion={error} pixels={pixels}"); + assert!(error < 1e-8 && pixels < 1e-6, "3D children must not inherit parent source pixel aspect"); +} + +fn rotation_properties(s: &Session, c: u64) -> Vec { + ["rotationX", "rotationY", "rotation"] + .map(|name| s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().clone()) + .into() +} +fn axis_animation(s: &mut Session, c: u64) { + for (name, values) in [("rotationX", [-21.0, 35.0]), ("rotationY", [9.0, -30.0]), ("rotation", [12.0, 28.0])] { + for (time, value) in [(0.0, values[0]), (1.0, values[1])] { + command(s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value})); + } + } +} +#[test] +fn parenting_static_orientation_preserves_nonuniform_child_scale_and_axis_rotation_keys() { + let (mut s, p, c) = fixture(true, true); + set(&mut s, c, "transform/scale", json!([83.0, 127.0, 61.0])); + axis_animation(&mut s, c); + let rotations = rotation_properties(&s, c); + let old = s.project.clone(); + let undo = s.history.undo.len(); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + for i in &images { + finite_visible(i); + } + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + eprintln!("STATIC_AXIS_POSE corners={error} pixels={pixels}"); + assert!(error < 1e-8 && pixels < 2e-5, "static Orientation must preserve pose with nonuniform child Scale and authored axis Rotation animation"); + assert_eq!(rotation_properties(&s, c), rotations, "axis Rotation properties are byte-for-byte authored"); + assert_eq!(s.history.undo.len(), undo + 1); + let attached = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&s.project, &old)); + assert!(s.redo()); + assert!(Arc::ptr_eq(&s.project, &attached)); + parent(&mut s, c, None, true); + for (t, b) in times.into_iter().zip(before) { + assert!(displacement(b, world(&s, c, t)) < 1e-8); + } + assert_eq!(rotation_properties(&s, c), rotations); +} +#[test] +fn parenting_uniform_parent_preserves_animated_eased_scale() { + use effectcraft_engine::keyframe::{Ease, Interp}; + let (mut s, p, c) = fixture(true, true); + for (time, value) in [(0.0, json!([80.0, 120.0, 95.0])), (1.0, json!([135.0, 75.0, 150.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/scale","time":time,"value":value})); + } + let cid = s.active_comp_id().unwrap(); + let scale = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/scale").unwrap(); + for key in &mut scale.keys { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }, Ease { speed: -13.0, influence: 0.6 }, Ease { speed: 7.0, influence: 0.4 }]; + key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }, Ease { speed: -19.0, influence: 0.2 }, Ease { speed: 13.0, influence: 0.5 }]; + } + let old_scale = scale.clone(); + let rotations = rotation_properties(&s, c); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + for image in &images { + finite_visible(image); + } + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + eprintln!("EASED_SCALE_POSE corners={error} pixels={pixels}"); + assert!(error < 1e-8 && pixels < 2e-5, "uniform parent compensation must preserve animated eased Scale poses"); + assert_eq!(rotation_properties(&s, c), rotations); + let scale = s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/scale").unwrap(); + assert_eq!(scale.keys.iter().map(|k| k.time).collect::>(), old_scale.keys.iter().map(|k| k.time).collect::>()); + for (old, new) in old_scale.keys.iter().zip(&scale.keys) { + for (old, new) in old.in_ease.iter().chain(&old.out_ease).zip(new.in_ease.iter().chain(&new.out_ease)) { + assert!((new.speed - old.speed / 1.2).abs() < 1e-9); + assert_eq!(new.influence, old.influence); + } + } +} +#[test] +fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() { + let mut max_corners = 0.0f64; + let mut max_pixels = 0.0f32; + for factor in [120.0, -120.0] { + for orientation in [[23.0, 90.0, -37.0], [-41.0, -90.0, 16.0]] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); + set(&mut s, p, "transform/rotationX", json!(0.0)); + set(&mut s, p, "transform/rotationY", json!(0.0)); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + set(&mut s, c, "transform/orientation", json!(orientation)); + set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); + let before = world(&s, c, Tick::ZERO); + let image = cpu(&s, Tick::ZERO); + assert!(image.data.iter().flatten().all(|v| v.is_finite())); + assert!(image.data.iter().any(|p| p[3] > 0.05), "gimbal control must render visible pixels"); + let rotations = rotation_properties(&s, c); + parent(&mut s, c, Some(p), true); + let error = displacement(before, world(&s, c, Tick::ZERO)); + let pixels = diff(&image, &cpu(&s, Tick::ZERO)); + max_corners = max_corners.max(error); + max_pixels = max_pixels.max(pixels); + assert_eq!(rotation_properties(&s, c), rotations); + } + } + eprintln!("SIGNED_GIMBAL_POSE corners={max_corners} pixels={max_pixels}"); + assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); +} + +#[test] +fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { + for animated in [true, false] { + let (mut s, p, c) = fixture(true, true); + if animated { + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + } else { + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); + } + let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); + let orientation = layer.props.prop("transform/orientation").unwrap().clone(); + let scale = layer.props.prop("transform/scale").unwrap().clone(); + let before = world(&s, c, Tick::ZERO); + parent(&mut s, c, Some(p), true); + let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); + assert_eq!(layer.props.prop("transform/orientation").unwrap(), &orientation, "unsupported animated Euler/shear receives no blind Orientation rewrite"); + assert_eq!(layer.props.prop("transform/scale").unwrap(), &scale, "unsupported animated Euler/shear receives no blind Scale rewrite"); + assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "boundary retains already-proved Position behavior"); + assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite())); + } +} + +/// Position's temporal speed is measured along its spatial path, in parent-space units. +#[test] +fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { + use effectcraft_engine::keyframe::{Ease, Interp}; + let mut maximum_error = 0.0f64; + for factor in [100.0, 200.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); + } + let cid = s.active_comp_id().unwrap(); + let position = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap(); + for key in &mut position.keys { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.in_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }]; + key.out_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }]; + key.auto_bezier = false; + key.spatial_auto = false; + key.spatial_in = [0.0; 3]; + key.spatial_out = [0.0; 3]; + } + let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + assert!(anchor_motion(before[0], before[1]) > 1.0, "the eased Position fixture must actually move at quarter time"); + let old = s.project.clone(); + let undo = s.history.undo.len(); + // Omit compensate to exercise the public default without depending on the schema-only fix. + s.execute_checked("layer.setParent", json!({"layers":[c],"parent":p})).unwrap(); + let after = times.map(|t| world(&s, c, t)); + assert!(after.iter().all(|m| m.0.iter().flatten().all(|v| v.is_finite()))); + for i in [0, 3] { + assert!(anchor_motion(before[i], after[i]) < 1e-8, "parent compensation must preserve the authored Position endpoints"); + } + for i in [1, 2] { + let error = anchor_motion(before[i], after[i]); + maximum_error = maximum_error.max(error); + eprintln!("EASED_POSITION_PARENT factor={factor} time={} anchor_error={error}", times[i].seconds()); + } + assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action"); + let attached = s.project.clone(); + assert!(s.undo()); + assert!(Arc::ptr_eq(&old, &s.project), "Undo restores the exact authored project"); + for (t, expected) in times.into_iter().zip(before) { + assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Undo restores all sampled world anchors"); + } + assert!(s.redo()); + assert!(Arc::ptr_eq(&attached, &s.project), "Redo restores the exact compensated project"); + for (t, expected) in times.into_iter().zip(after) { + assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Redo retains the same compensated samples"); + } + } + assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times"); +} diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 67ff2736..098a8c11 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -6,7 +6,7 @@ use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; -use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use effectcraft_time::{FrameRate, Tick}; use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; @@ -331,7 +331,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St return Ok(()); } let n = (end - *cursor) as usize; - let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); + let start = Tick::from_units(*cursor, i64::from(sr)); + let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start }; let buf = mix(job, start, n, sr); *cursor = end; match a { diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index c3c136d1..a12bd4e1 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -344,7 +344,9 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R let mut reported = 0u64; while done < n { let k = block.min(n - done); - let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); + let sample = start + done as i64; + let t = Tick::from_units(sample, i64::from(sr)); + let t = if t.to_units_floor(i64::from(sr)) < sample { Tick(t.0.saturating_add(1)) } else { t }; pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff)); done += k; let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames); diff --git a/crates/export/tests/audio_sample_positions.rs b/crates/export/tests/audio_sample_positions.rs new file mode 100644 index 00000000..0fa0deb9 --- /dev/null +++ b/crates/export/tests/audio_sample_positions.rs @@ -0,0 +1,125 @@ +//! Sample positions through the public native WAV exporter, using a tiny recorded impulse. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, Tick}; +use std::sync::{Arc, Mutex}; + +struct Impulse { + first: i64, + last: i64, + calls: Mutex>, +} +impl FootageSource for Impulse { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + let index = start.to_units_floor(i64::from(rate)); + self.calls.lock().unwrap().push((start, index, frames, rate)); + let mut samples = Vec::with_capacity(frames * 2); + for offset in 0..frames { + let sample = index + offset as i64; + let value = if sample == self.first { + 0.75 + } else if sample == self.last { + 0.25 + } else { + 0.0 + }; + samples.extend([value, -value]); + } + Some(samples) + } +} +fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + let mut offset = 12; + while offset + 8 <= bytes.len() { + let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize; + if &bytes[offset..offset + 4] == name { + return &bytes[offset + 8..offset + 8 + length]; + } + offset += 8 + length + length % 2; + } + panic!("native WAV chunk missing"); +} +fn check(rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Match the exporter's existing floor-to-sample span convention; this proof tests whether + // its selected samples survive subsequent Tick conversions, without changing that convention. + let first = span.0.to_units_floor(i64::from(rate)); + let end = span.1.to_units_floor(i64::from(rate)); + let expected_frames = usize::try_from(end - first).unwrap(); + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item( + "Impulse", + Default::default(), + None, + ItemKind::Footage(Footage { path: "recorded-sample-impulse.wav".into(), kind: FootageKind::Audio, duration, has_audio: true, ..Default::default() }), + ); + let mut comp = Comp::new(4, 4, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Impulse", LayerSource::Footage { item }, (4, 4), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::Wav); + output.audio_sample_rate = rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let provider = Impulse { first, last: end - 1, calls: Mutex::default() }; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "impulse.wav", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio, "the production native audio exporter ran"); + let files = files.lock().unwrap(); + let bytes = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1; + assert_eq!(&bytes[..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + let format = chunk(bytes, b"fmt "); + assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3, "float PCM"); + assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), rate); + let pcm = chunk(bytes, b"data"); + assert_eq!(pcm.len(), expected_frames * 8, "the selected work-area sample count is unchanged"); + let samples: Vec = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect(); + let impulses: Vec = samples.as_chunks::<2>().0.iter().enumerate().filter_map(|(i, pair)| (pair[0] != 0.0).then_some(i)).collect(); + let calls = provider.calls.lock().unwrap(); + eprintln!("rate={rate} first={first} end={end} frames={expected_frames} calls={calls:?} exported_impulses={impulses:?}"); + assert!(!calls.is_empty(), "native export actually requested provider samples"); + assert_eq!( + samples[..2], + [0.75, -0.75], + "the first selected source sample must be the first exported sample at {rate} Hz; impulses={impulses:?}, calls={calls:?}" + ); + assert_eq!(samples[samples.len() - 2..], [0.25, -0.25], "the final selected source sample must be preserved at {rate} Hz"); + assert_eq!(impulses, [0, expected_frames - 1], "no shifted or duplicated impulse"); +} +#[test] +fn native_wav_work_area_preserves_44100_sample_positions() { + check(44_100); +} +#[test] +fn native_wav_work_area_preserves_48000_sample_positions() { + check(48_000); +} +#[test] +fn native_wav_work_area_preserves_44117_sample_positions() { + check(44_117); +} diff --git a/crates/export/tests/mov_audio_sample_positions.rs b/crates/export/tests/mov_audio_sample_positions.rs new file mode 100644 index 00000000..c58d2b14 --- /dev/null +++ b/crates/export/tests/mov_audio_sample_positions.rs @@ -0,0 +1,220 @@ +#![cfg(not(target_arch = "wasm32"))] + +//! Tiny native WAV to ProRes PCM MOV sample-position proof. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use std::sync::{Arc, Mutex}; + +struct RecordingNative { + pool: MediaPool, + calls: Mutex>, +} +impl FootageSource for RecordingNative { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate)); + Some(self.pool.audio_samples(footage, start, frames, rate)) + } +} +fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> { + let mut pcm = Vec::with_capacity(native_rate as usize * 8); + for index in 0..i64::from(native_rate) { + let pair = if impulses { + let value: f32 = if index == first { + 0.75 + } else if index == last { + 0.25 + } else { + 0.0 + }; + [value, -value] + } else { + [(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0] + }; + pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes)); + } + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(3u16.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(native_rate.to_le_bytes()); + wav.extend((native_rate * 8).to_le_bytes()); + wav.extend(8u16.to_le_bytes()); + wav.extend(32u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(pcm); + wav.into() +} +fn boxes(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut offset = 0usize; + while offset + 8 <= bytes.len() { + let size = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let name = bytes[offset + 4..offset + 8].try_into().unwrap(); + let (size, header) = if size == 1 { + (usize::try_from(u64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().unwrap())).unwrap(), 16) + } else if size == 0 { + (bytes.len() - offset, 8) + } else { + (size as usize, 8) + }; + assert!(size >= header && offset + size <= bytes.len(), "valid returned MOV boxes"); + out.push((name, &bytes[offset + header..offset + size])); + offset += size; + } + assert_eq!(offset, bytes.len()); + out +} +fn child<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + boxes(bytes).into_iter().find(|(kind, _)| kind == name).unwrap().1 +} +fn movie_pcm_count(bytes: &[u8], rate: u32, frames: usize) { + let moov = child(bytes, b"moov"); + let mdia = boxes(moov) + .into_iter() + .filter(|(kind, _)| kind == b"trak") + .map(|(_, track)| child(track, b"mdia")) + .find(|media| &child(media, b"hdlr")[8..12] == b"soun") + .expect("the returned MOV has a sound track"); + let header = child(mdia, b"mdhd"); + let (timescale, duration) = if header[0] == 1 { + (u32::from_be_bytes(header[20..24].try_into().unwrap()), u64::from_be_bytes(header[24..32].try_into().unwrap())) + } else { + (u32::from_be_bytes(header[12..16].try_into().unwrap()), u64::from(u32::from_be_bytes(header[16..20].try_into().unwrap()))) + }; + assert_eq!(timescale, rate, "PCM MOV audio timescale"); + assert_eq!(duration, frames as u64, "PCM MOV selected sample count"); + let table = child(child(mdia, b"minf"), b"stbl"); + let timing = child(table, b"stts"); + let count = u32::from_be_bytes(timing[4..8].try_into().unwrap()) as usize; + let duration: u64 = timing[8..] + .as_chunks::<8>() + .0 + .iter() + .take(count) + .map(|entry| u64::from(u32::from_be_bytes(entry[..4].try_into().unwrap())) * u64::from(u32::from_be_bytes(entry[4..].try_into().unwrap()))) + .sum(); + assert_eq!(duration, frames as u64, "all encoded PCM samples are counted"); + let sizes = child(table, b"stsz"); + let fixed = u32::from_be_bytes(sizes[4..8].try_into().unwrap()) as u64; + let count = u32::from_be_bytes(sizes[8..12].try_into().unwrap()) as usize; + let bytes = if fixed != 0 { + fixed * count as u64 + } else { + sizes[12..].as_chunks::<4>().0.iter().take(count).map(|entry| u64::from(u32::from_be_bytes(*entry))).sum() + }; + assert_eq!(bytes, (frames * 8) as u64, "native stereo float PCM bytes"); +} +fn first_sample_tick(index: i64, rate: u32) -> Tick { + let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND); + let denominator = i128::from(rate); + Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64) +} +fn check(native_rate: u32, output_rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Keep the production exporter's floor-to-sample span selection. The oracle below requests + // that same first selected integer sample directly from the actual native decoder. + let first = span.0.to_units_floor(i64::from(output_rate)); + let end = span.1.to_units_floor(i64::from(output_rate)); + let frames = usize::try_from(end - first).unwrap(); + let impulses = native_rate == output_rate; + let bytes = native_wav(native_rate, first, end - 1, impulses); + let mut mismatches = Vec::new(); + for loops in [1, 2] { + let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav"); + let mut footage = probe_bytes(&path, bytes.clone()).unwrap(); + assert!(footage.has_audio && !footage.missing); + footage.loop_count = loops; + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes.clone()); + let oracle_tick = first_sample_tick(first, output_rate); + assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first); + let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate); + assert_eq!(expected.len(), frames * 2); + assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent"); + if impulses { + assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse"); + assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse"); + } + let provider = RecordingNative { pool, calls: Mutex::default() }; + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage)); + let mut comp = Comp::new(16, 16, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (16, 16), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::ProRes); + output.audio_sample_rate = output_rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "native-impulse.mov", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio); + let files = files.lock().unwrap(); + let movie = &files.iter().find(|(name, _)| name.ends_with(".mov")).unwrap().1; + assert_eq!(report.frames, 1, "the one-frame ProRes movie was actually encoded"); + movie_pcm_count(movie, output_rate, frames); + let decoded = probe_bytes("returned-native-impulse.mov", Arc::from(movie.as_slice())).unwrap(); + assert!(decoded.has_audio && decoded.has_video, "the native movie decoder recognized both tracks"); + let decoder = MediaPool::new(); + decoder.add_bytes(&decoded.path, Arc::from(movie.as_slice())); + let decoded_pcm = decoder.audio_samples(&decoded, Tick::ZERO, frames + 8, output_rate); + assert_eq!(decoded_pcm.len(), (frames + 8) * 2); + assert!(decoded_pcm[frames * 2..].iter().all(|value| *value == 0.0), "native PCM ends at the selected sample count"); + let actual = decoded_pcm[..frames * 2].to_vec(); + let calls = provider.calls.lock().unwrap(); + assert!(!calls.is_empty(), "production export actually called the real native provider"); + eprintln!( + "native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}", + &actual[..2], + &expected[..2], + &actual[actual.len() - 2..], + &expected[expected.len() - 2..] + ); + if actual != expected { + let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b); + mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec())); + } + } + assert!(mismatches.is_empty(), "production native MOV PCM export must preserve the native provider's selected sample positions: {mismatches:?}"); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_44100_positions() { + check(44_100, 44_100); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_48000_positions() { + check(48_000, 48_000); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_44117_positions() { + check(44_117, 44_117); +} diff --git a/crates/export/tests/native_audio_sample_positions.rs b/crates/export/tests/native_audio_sample_positions.rs new file mode 100644 index 00000000..1b59e8c1 --- /dev/null +++ b/crates/export/tests/native_audio_sample_positions.rs @@ -0,0 +1,169 @@ +#![cfg(not(target_arch = "wasm32"))] + +//! Native MediaPool decoding and native WAV export preserve selected sample positions. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use std::sync::{Arc, Mutex}; + +struct RecordingNative { + pool: MediaPool, + calls: Mutex>, +} +impl FootageSource for RecordingNative { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate)); + Some(self.pool.audio_samples(footage, start, frames, rate)) + } +} +fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> { + let mut pcm = Vec::with_capacity(native_rate as usize * 8); + for index in 0..i64::from(native_rate) { + let pair = if impulses { + let value: f32 = if index == first { + 0.75 + } else if index == last { + 0.25 + } else { + 0.0 + }; + [value, -value] + } else { + [(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0] + }; + pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes)); + } + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(3u16.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(native_rate.to_le_bytes()); + wav.extend((native_rate * 8).to_le_bytes()); + wav.extend(8u16.to_le_bytes()); + wav.extend(32u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(pcm); + wav.into() +} +fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + let mut offset = 12; + while offset + 8 <= bytes.len() { + let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize; + if &bytes[offset..offset + 4] == name { + return &bytes[offset + 8..offset + 8 + length]; + } + offset += 8 + length + length % 2; + } + panic!("native WAV chunk missing"); +} +fn first_sample_tick(index: i64, rate: u32) -> Tick { + let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND); + let denominator = i128::from(rate); + Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64) +} +fn check(native_rate: u32, output_rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Keep the production exporter's floor-to-sample span selection. The oracle below requests + // that same first selected integer sample directly from the actual native decoder. + let first = span.0.to_units_floor(i64::from(output_rate)); + let end = span.1.to_units_floor(i64::from(output_rate)); + let frames = usize::try_from(end - first).unwrap(); + let impulses = native_rate == output_rate; + let bytes = native_wav(native_rate, first, end - 1, impulses); + let mut mismatches = Vec::new(); + for loops in [1, 2] { + let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav"); + let mut footage = probe_bytes(&path, bytes.clone()).unwrap(); + assert!(footage.has_audio && !footage.missing); + footage.loop_count = loops; + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes.clone()); + let oracle_tick = first_sample_tick(first, output_rate); + assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first); + let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate); + assert_eq!(expected.len(), frames * 2); + assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent"); + if impulses { + assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse"); + assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse"); + } + let provider = RecordingNative { pool, calls: Mutex::default() }; + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage)); + let mut comp = Comp::new(4, 4, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (4, 4), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::Wav); + output.audio_sample_rate = output_rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "native-impulse.wav", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio); + let files = files.lock().unwrap(); + let wav = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1; + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + let format = chunk(wav, b"fmt "); + assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3); + assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), output_rate); + let pcm = chunk(wav, b"data"); + assert_eq!(pcm.len(), frames * 8, "native work-area sample count"); + let actual: Vec = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect(); + let calls = provider.calls.lock().unwrap(); + assert!(!calls.is_empty(), "production export actually called the real native provider"); + eprintln!( + "native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}", + &actual[..2], + &expected[..2], + &actual[actual.len() - 2..], + &expected[expected.len() - 2..] + ); + if actual != expected { + let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b); + mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec())); + } + } + assert!(mismatches.is_empty(), "production native WAV export must preserve the native provider's selected sample positions: {mismatches:?}"); +} +#[test] +fn native_media_wav_work_area_preserves_44100_positions() { + check(44_100, 44_100); +} +#[test] +fn native_media_wav_work_area_preserves_48000_positions() { + check(48_000, 48_000); +} +#[test] +fn native_media_wav_work_area_preserves_44117_positions() { + check(44_117, 44_117); +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index aa202539..5756365a 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -932,8 +932,10 @@ __TextStyle.prototype = { setVerticalScaling: __styleSetter('vScale'), setTsume: __styleSetter('tsume'), setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }), - setSuperscript: __styleSetter('superscript', Boolean), - setSubscript: __styleSetter('subscript', Boolean), + // Canonical baseline operations also make the returned TextStyle's getters reflect + // the pending edit, rather than waiting for the host to apply a superscript flag. + setSuperscript: __styleSetter('baseline', function (v) { return v ? 'superscript' : 'normal'; }), + setSubscript: __styleSetter('baseline', function (v) { return v ? 'subscript' : 'normal'; }), setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }), setKerning: __styleSetter('kerning', Number), setLigature: __styleSetter('ligatures', Boolean), diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 4c638205..3d5acb2d 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1160,3 +1160,15 @@ fn thousands_of_host_reads_on_one_thread_keep_working() { assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}"); } } + +#[test] +fn pending_text_style_baseline_setters_are_visible_to_expression_getters() { + let x = fx(); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).isSuperscript ? 1 : 0", 0.0), 1.0); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSuperscript ? 1 : 0", 0.0), 0.0); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).setSubscript(false).isSubscript ? 1 : 0", 0.0), 0.0); + assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0); + assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super"); +} diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 57c2037f..1b1b42be 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -216,6 +216,7 @@ fn composite_mode(m: u32) -> BlendMode { fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { let pr = ctx.params; let mut p = Params::default(); + let mut original = None; match id { "ec.utility.cineon" => { // utility::Cineon's offset and knee, in the CPU's f32. @@ -246,6 +247,11 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = pr.color("highlightColor"); } "ec.channel.cccomposite" => { + if let Some(source) = ctx.env.host.and_then(|h| h.original()) { + let fitted = effectcraft_effects::util::fit_buffer(source, b.img.width, b.img.height, b.offset, b.scale); + original = Some(e.g.upload_image(&fitted)?); + } + p.u[1][0] = original.is_some() as u32; let m = pr.e("compositeOriginal"); let mode = if m == 1 { BlendMode::Normal } else { composite_mode(m) }; p.u[0] = [6, (m == 1) as u32, mode_id(mode), pr.b("rgbOnly") as u32]; @@ -267,7 +273,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { } _ => return None, } - run(e, "fp2_point", &p, b, None, None) + run(e, "fp2_point", &p, b, original.as_ref(), None) } // ---------------------------------------------------------------- CC Block Load (stylize3) diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index c2ff0c87..21bb8b5d 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -402,7 +402,9 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3) { } // u[0] = (_, composite original, mode, rgb only); f[0].x = opacity. default: { - let orig = px * P.f[0].x; + var source = px; + if (P.u[1].x != 0u) { source = textureLoad(aux, p, 0); } + let orig = source * P.f[0].x; if (P.u[0].y == 1u) { o = blend_pixel(P.u[0].z, orig, px, 0.5); } else { diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index 7d6d47bf..81bfaf10 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -103,3 +103,19 @@ fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { r.run_effects_on(&ctx, adj, true, &mut p); assert_eq!((p.gpu, p.cpu), (3, 1)); } + +#[test] +fn composite_reads_the_original_adjustment_input_on_gpu() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [0, 1, 2, 4, 15] { + for rgb in [false, true] { + let stack = vec![ + ("ec.channel.invert", vec![]), + ("ec.channel.cccomposite", vec![("compositeOriginal", Value::Enum(mode)), ("opacity", n(65.0)), ("rgbOnly", Value::Bool(rgb))]), + ]; + let s = adjustment_scene(depth, &stack, true); + check(&format!("original adjustment {depth:?} mode {mode} rgb {rgb}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } + } +} diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs new file mode 100644 index 00000000..384f86a0 --- /dev/null +++ b/crates/host/tests/footage_reload.rs @@ -0,0 +1,196 @@ +//! Footage changed on disk by another app, through the fully wired session (media decoding +//! included): File ▸ Reload Footage shows the new pixels and keeps a Photoshop layer item on its +//! layer, and `footage.reloadChanged` (Settings ▸ Import ▸ Automatically Reload Footage) finds +//! changed files by itself. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_engine::{Session, footage_reload}; +use effectcraft_project::{Footage, ItemId, ItemKind}; +use effectcraft_psd::Rect; +use effectcraft_psd::write::*; +use effectcraft_time::Tick; +use serde_json::json; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-host-reload-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +/// A one-colour document; `extra` adds a hidden layer, so the file's size changes too. +fn flat(rgba: [f32; 4], extra: bool) -> Vec { + let mut d = WDoc::new(64, 48); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)]; + if extra { + let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED); + hidden.hidden = true; + d.layers.push(hidden); + } + d.composite = Some(vec![rgba; 64 * 48]); + write(&d) +} + +/// Write `bytes` to `path` so its modification time moves even on coarse file systems. +fn save(path: &str, bytes: &[u8]) { + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(path, bytes).unwrap(); +} + +fn footage(s: &Session, id: ItemId) -> Footage { + match &s.project.item(id).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + k => panic!("not footage: {k:?}"), + } +} + +/// The colour (straight RGB) at `(x, y)` of comp `cid` at time 0. +fn pixel(s: &Session, cid: ItemId, x: usize, y: usize) -> [f32; 3] { + let img = s.render(cid, Tick::ZERO, RenderOpts::default()); + let p = img.data[y * img.width as usize + x]; + [p[0], p[1], p[2]].map(|c| if p[3] > 0.0 { c / p[3] } else { 0.0 }) +} + +fn near(a: [f32; 3], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02) +} + +/// A flat file as footage in a comp of its size: (session, footage item, comp). +fn flat_in_comp(path: &str) -> (Session, ItemId, ItemId) { + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap(); + s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let cid = s.active_comp_id().unwrap(); + (s, item, cid) +} + +#[test] +fn reload_footage_shows_the_new_pixels() { + let path = tmp("flat.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, item, cid) = flat_in_comp(&path); + assert!(near(pixel(&s, cid, 32, 24), RED)); + // Same size, new colour: the frames decoded before must not be shown again. + save(&path, &flat(BLUE, false)); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item.0]})).unwrap(); + assert_eq!(r["reloaded"], 1); + assert!(near(pixel(&s, cid, 32, 24), BLUE), "{:?}", pixel(&s, cid, 32, 24)); +} + +#[test] +fn reload_keeps_a_photoshop_layer_item_on_its_layer() { + let path = tmp("layers.psd"); + let mut d = WDoc::new(64, 48); + d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)]; + std::fs::write(&path, write(&d)).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let items: Vec = r["items"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(ItemId).collect(); + let patch = *items.iter().find(|i| s.project.item(**i).is_some_and(|it| it.name.starts_with("Patch/"))).unwrap(); + assert_eq!((footage(&s, patch).width, footage(&s, patch).layer.unwrap().index), (32, 1)); + assert!(near(pixel(&s, cid, 8, 24), BLUE)); + // In the other app: a layer added under the patch, and the patch narrower and green. + d.layers = vec![ + WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), + WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED), + WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN), + ]; + save(&path, &write(&d)); + let footage_items: Vec = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect(); + s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap(); + let f = footage(&s, patch); + let l = f.layer.clone().expect("still one layer of the file"); + assert_eq!((l.name.as_str(), l.index, f.width, f.height), ("Patch", 2, 24, 48)); + assert!(!f.missing); + assert!(near(pixel(&s, cid, 8, 24), GREEN), "{:?}", pixel(&s, cid, 8, 24)); +} + +#[test] +fn changed_footage_reloads_by_itself_without_an_undo_step() { + let path = tmp("auto.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, _, cid) = flat_in_comp(&path); + s.mark_saved(); + let undo = s.history.undo.len(); + // First look: remembered, nothing to reload. + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + save(&path, &flat(BLUE, true)); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), BLUE)); + assert_eq!(s.history.undo.len(), undo, "the disk changed, not the project"); + assert!(!s.is_dirty()); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); +} + +#[test] +fn a_file_still_being_written_is_tried_again() { + let path = tmp("partial.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, item, cid) = flat_in_comp(&path); + s.execute_checked("footage.reloadChanged", json!({})).unwrap(); + // Another app has only written the start of the file so far. + let full = flat(BLUE, true); + save(&path, &full[..12]); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + assert!(!footage(&s, item).missing, "a file being written isn't missing"); + // Meanwhile it renders as whatever part of it can be decoded (here nothing); the next scan, + // after the other app has finished, reads it whole. + save(&path, &full); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), BLUE)); +} + +#[test] +fn background_scan_reloads_changed_footage() { + let path = tmp("scan.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + let (mut s, _, cid) = flat_in_comp(&path); + let scan = |s: &mut Session| { + footage_reload::start_scan(s); + let t0 = std::time::Instant::now(); + loop { + if let Some(r) = footage_reload::poll_scan(s) { + return r.unwrap(); + } + assert!(t0.elapsed().as_secs() < 10, "scan never finished"); + std::thread::yield_now(); + } + }; + assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + save(&path, &flat(GREEN, true)); + assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert!(near(pixel(&s, cid, 32, 24), GREEN)); + assert!(s.footage_scan.is_none()); +} + +#[test] +fn image_sequences_reload_by_themselves_only_with_all_footage() { + let png = |path: &str, v: u8| { + std::thread::sleep(std::time::Duration::from_millis(30)); + image::RgbaImage::from_pixel(8, 8, image::Rgba([v, 0, 0, 255])).save(path).unwrap(); + }; + let (a, b) = (tmp("seq_0001.png"), tmp("seq_0002.png")); + png(&a, 255); + png(&b, 255); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [&a], "sequence": true})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + assert_eq!(footage(&s, item).sequence.len(), 2, "imported as a sequence"); + // Non-Sequence Footage (the default, as in After Effects): sequences are never stamped. + assert_eq!(s.prefs.import.auto_reload_footage, "nonSequence"); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + png(&a, 0); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + // All Footage: every frame is watched (first seen now, so only remembered). + s.prefs.import.auto_reload_footage = "all".into(); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0})); + std::fs::write(&b, b"not yet a png").unwrap(); + png(&b, 128); + assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap()["files"], json!(1)); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 3db4b1a9..0d7803fd 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,6 +24,7 @@ filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } exr = { workspace = true } +gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } rayon = { workspace = true } serde_json = { workspace = true } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 114873e7..d9ef9323 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -104,6 +104,67 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - img } +/// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way: +/// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels. +/// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma, +/// and the [1 6 1]/8 neighbourhood of its chroma sample: the mean of the upsampled chroma its +/// pixels get), then resampled the rest of the way. Conversion is linear until it clips, so +/// this is the RGB box filter `resample` starts with, except in blocks where some colours clip. +/// `None` for other frames. +pub(crate) fn frame_to_image_scaled(f: &VideoFrame, w: u32, h: u32) -> Option { + let (fw, fh) = (f.width as usize, f.height as usize); + if w == 0 || h == 0 || fw % 2 != 0 || fh % 2 != 0 || (w as usize).saturating_mul(2) > fw || (h as usize).saturating_mul(2) > fh { + return None; + } + let half = match &f.data { + PixelData::Yuv8 { planes, chroma: Chroma::C420, alpha: None } => { + half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, 8))? + } + PixelData::Yuv16 { planes, chroma: Chroma::C420, bits, alpha: None } => { + half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, *bits))? + } + _ => return None, + }; + Some(if (half.width, half.height) == (w, h) { half } else { effectcraft_raster::resample(&half, w, h) }) +} + +/// An opaque 4:2:0 frame of even size `w` × `h` converted at half that size (see +/// [`frame_to_image_scaled`]); `None` when the planes are too short. +fn half_420(w: usize, h: usize, [luma, cb, cr]: [&[T]; 3], k: &Yuv) -> Option { + /// Row `r` of a plane `n` samples wide (empty past its end). + fn row_of(p: &[T], r: usize, n: usize) -> &[T] { + p.get(r.saturating_mul(n)..(r + 1).saturating_mul(n)).unwrap_or_default() + } + let (cw, ch) = (w / 2, h / 2); + if luma.len() < w.checked_mul(h)? || cb.len() < cw * ch || cr.len() < cw * ch { + return None; + } + let mut img = Image::new(cw as u32, ch as u32); + img.data.par_chunks_mut(cw).enumerate().for_each_init( + // Chroma rows with their edge samples repeated on both ends, for the horizontal [1 6 1]. + || (vec![0f32; cw + 2], vec![0f32; cw + 2]), + |(uc, vc), (cy, row)| { + // The chroma rows above and below weigh 1/8, as `centred` upsampling averages out. + let rows = [cy.saturating_sub(1), cy, (cy + 1).min(ch - 1)]; + for (dst, plane) in [(&mut *uc, cb), (&mut *vc, cr)] { + let [above, at, below] = rows.map(|r| row_of(plane, r, cw)); + for (d, ((a, b), c)) in dst[1..=cw].iter_mut().zip(above.iter().zip(at).zip(below)) { + *d = ((a.f() + c.f()) * 0.125 + b.f() * 0.75 - k.c_off) * k.c_scale; + } + (dst[0], dst[cw + 1]) = (dst[1], dst[cw]); + } + let (top, bottom) = (row_of(luma, 2 * cy, w), row_of(luma, 2 * cy + 1, w)); + let pixels = top.chunks_exact(2).zip(bottom.chunks_exact(2)).zip(uc.windows(3).zip(vc.windows(3))); + for (o, ((a, b), (u, v))) in row.iter_mut().zip(pixels) { + let (u, v) = ((u[0] + u[2]) * 0.125 + u[1] * 0.75, (v[0] + v[2]) * 0.125 + v[1] * 0.75); + let yy = ((a[0].f() + a[1].f() + b[0].f() + b[1].f()) * 0.25 - k.y_off) * k.y_scale; + *o = [(yy + k.cr_r * v).clamp(0.0, 1.0), (yy - k.cr_g * v - k.cb_g * u).clamp(0.0, 1.0), (yy + k.cb_b * u).clamp(0.0, 1.0), 1.0]; + } + }, + ); + Some(img) +} + /// 0..255 → 0..1. static U8: [f32; 256] = { let mut t = [0f32; 256]; @@ -338,6 +399,47 @@ mod tests { assert!((img.data[0][0] - 110.0 / 219.0).abs() < 1e-5); } + /// A smooth opaque 4:2:0 frame whose colours never clip. + fn smooth_420(w: usize, h: usize) -> VideoFrame { + let plane = |w: usize, h: usize, base: f32, amp: f32, fx: f32, fy: f32| -> Arc> { + Arc::new((0..w * h).map(|i| (base + amp * ((i % w) as f32 * fx + (i / w) as f32 * fy).sin()) as u8).collect()) + }; + VideoFrame { + width: w as u32, + height: h as u32, + data: PixelData::Yuv8 { + planes: [plane(w, h, 125.0, 45.0, 0.31, 0.17), plane(w / 2, h / 2, 128.0, 10.0, 0.4, -0.3), plane(w / 2, h / 2, 128.0, 10.0, -0.2, 0.5)], + chroma: Chroma::C420, + alpha: None, + }, + color: filmcraft_color::ColorInfo::REC709, + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + } + } + + #[test] + fn scaled_conversion_matches_resampling_the_converted_frame() { + let f = smooth_420(96, 64); + let full = frame_to_image(&f, AlphaOp::new(AlphaMode::Straight, [0.0; 3])); + for (w, h) in [(48, 32), (32, 21), (24, 16), (13, 9)] { + let scaled = frame_to_image_scaled(&f, w, h).expect("4:2:0, at most half size"); + let want = effectcraft_raster::resample(&full, w, h); + assert_eq!((scaled.width, scaled.height), (w, h)); + let worst = scaled.data.iter().zip(&want.data).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max); + assert!(worst < 1e-5, "{w}×{h}: {worst}"); + } + // More than half the size, odd sizes, other chroma layouts: convert the full frame. + assert!(frame_to_image_scaled(&f, 49, 32).is_none()); + assert!(frame_to_image_scaled(&smooth_420(95, 64), 40, 30).is_none()); + assert!(frame_to_image_scaled(&yuv_frame(100, 128, 128, filmcraft_color::ColorInfo::REC709), 2, 1).is_some()); + let mut f444 = smooth_420(96, 64); + if let PixelData::Yuv8 { chroma, .. } = &mut f444.data { + *chroma = Chroma::C444; + } + assert!(frame_to_image_scaled(&f444, 48, 32).is_none()); + } + #[test] fn bt709_red() { // 8-bit limited BT.709 red: Y 63, Cb 102, Cr 240 @@ -402,6 +504,11 @@ mod tests { keep.push(frame_to_image(&f, op)); } eprintln!("convert into fresh memory {:?}", t.elapsed() / 20); + let t = std::time::Instant::now(); + for _ in 0..20 { + std::hint::black_box(frame_to_image_scaled(&f, 640, 360)); + } + eprintln!("convert at a third {:?}", t.elapsed() / 20); } } } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 93836dcc..06bda925 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -18,6 +18,17 @@ fn is_depth(name: &str) -> bool { last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") } +/// Full plane name of a channel in a part: `part.channel`, except that Blender 5 writes one part +/// per pass whose channels already carry the full name (part `ViewLayer.Depth` holds +/// `ViewLayer.Depth.Z`), which is kept as is instead of doubling the prefix. +fn channel_name(part: Option<&str>, channel: &str) -> String { + match part { + Some(p) if channel.strip_prefix(p).is_some_and(|rest| rest.starts_with('.')) => channel.to_string(), + Some(p) => format!("{p}.{channel}"), + None => channel.to_string(), + } +} + /// Parse a Cryptomatte manifest (`{"name": "hexhash", …}`). pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { let Ok(serde_json::Value::Object(m)) = serde_json::from_str::(json) else { return vec![] }; @@ -59,10 +70,7 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { let pos = layer.attributes.layer_position; let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64); for ch in layer.channel_data.list.iter() { - let name = match &prefix { - Some(p) => format!("{p}.{}", ch.name), - None => ch.name.to_string(), - }; + let name = channel_name(prefix.as_deref(), &ch.name.to_string()); let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 }; let mut plane = vec![fill; w * h]; let vals: Vec = ch.sample_data.values_as_f32().collect(); @@ -166,10 +174,21 @@ pub fn layered_image(bytes: &[u8]) -> Option { type Cache = Mutex>)>>; +fn cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Drop the cached channels of `path` (the file changed on disk). +pub(crate) fn forget(path: &str) { + if let Ok(mut c) = cache().lock() { + c.retain(|(p, _)| p != path); + } +} + /// Read (and cache, a few files) the channels of the EXR at `path` via `read`. pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option>) -> Option> { - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(Vec::new())); + let cache = cache(); if let Some(v) = cache.lock().ok().and_then(|c| c.iter().find(|(p, _)| p == path).map(|(_, v)| v.clone())) { return v; } @@ -244,6 +263,37 @@ mod tests { assert_eq!(aux.layer_rgba("diffuse"), ["diffuse.R", "diffuse.G", "diffuse.B", ""].map(String::from)); } + /// #481: Blender 5 writes one part per pass, its channels keeping the full pass name + /// (`ViewLayer.Depth.Z` in part `ViewLayer.Depth`); the name was doubled and the depth and + /// Cryptomatte ranks were not found. + #[test] + fn part_name_is_not_doubled_when_channels_carry_it() { + assert_eq!(channel_name(Some("ViewLayer.Depth"), "ViewLayer.Depth.Z"), "ViewLayer.Depth.Z"); + assert_eq!(channel_name(Some("depth"), "Z"), "depth.Z"); + assert_eq!(channel_name(Some("depth"), "depthZ"), "depth.depthZ"); + assert_eq!(channel_name(None, "R"), "R"); + assert_eq!(channel_name(None, "ViewLayer.Depth.Z"), "ViewLayer.Depth.Z"); + } + + #[test] + fn blender5_multipart_file_keeps_pass_names() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let part = |name: &str, chans: &[&str], v: f32| { + let list: Vec> = chans.iter().map(|c| AnyChannel::new(*c, FlatSamples::F32(vec![v; w * h]))).collect(); + Layer::new((w, h), LayerAttributes::named(name), Encoding::FAST_LOSSLESS, AnyChannels::sort(list.into())) + }; + let layers = + vec![part("ViewLayer.Depth", &["ViewLayer.Depth.Z"], 4.0), part("ViewLayer.Combined", &["ViewLayer.Combined.R", "ViewLayer.Combined.G"], 0.5)]; + let image = Image::from_layers(ImageAttributes::with_size((w, h)), layers); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let aux = read_exr_channels(bytes.get_ref()).unwrap(); + assert_eq!(aux.get("ViewLayer.Depth.Z").unwrap(), [4.0; 4].as_slice()); + assert!(aux.get("ViewLayer.Depth.ViewLayer.Depth.Z").is_none()); + assert!(aux.get("ViewLayer.Combined.R").is_some()); + } + /// Straight RGBA of [`layered_image`] at pixel 0 for a file of single-value channels. fn picture>(channels: &[(S, f32)]) -> Vec { use exr::prelude::*; diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs new file mode 100644 index 00000000..30161b0b --- /dev/null +++ b/crates/media/src/gif_anim.rs @@ -0,0 +1,209 @@ +//! Animated GIFs: probed as movies and decoded whole into 8-bit frames. +//! +//! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame +//! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the +//! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time. + +use std::io::Cursor; + +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder}; + +use crate::{MediaError, Result}; + +/// The `Footage::codec` of an animated GIF. +pub(crate) const CODEC: &str = "GIF"; + +/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab, +/// whose wasm32 heap is 4 GiB at most and shared with everything else). +#[cfg(not(target_arch = "wasm32"))] +const MAX_BYTES: usize = 1 << 30; +#[cfg(target_arch = "wasm32")] +const MAX_BYTES: usize = 256 << 20; + +/// Frames read from a GIF's headers when probing. +const MAX_FRAMES: usize = 100_000; + +/// Whether `f` is an animated GIF imported by [`probe`]. +pub(crate) fn is_gif(f: &Footage) -> bool { + f.kind == FootageKind::Video && f.codec == CODEC +} + +/// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders +/// write for "as fast as possible" as 10, and so do we. +fn delay_cs(raw: u32) -> u32 { + if raw < 2 { 10 } else { raw } +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +/// Describe an animated GIF as a movie. `None` for a GIF with fewer than two frames (a still) or +/// one whose headers can't be read. +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { + let mut dec = gif::DecodeOptions::new().read_info(Cursor::new(bytes)).ok()?; + let (width, height) = (u32::from(dec.width()), u32::from(dec.height())); + let mut delays: Vec = Vec::new(); + while delays.len() < MAX_FRAMES { + match dec.next_frame_info() { + Ok(Some(f)) => delays.push(delay_cs(u32::from(f.delay))), + // The end, or a damaged tail: keep the frames before it. + _ => break, + } + } + if delays.len() < 2 || width == 0 || height == 0 { + return None; + } + let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1); + let total: i64 = delays.iter().map(|&d| i64::from(d)).sum(); + Some(Footage { + path: path.to_string(), + kind: FootageKind::Video, + width, + height, + frame_rate: FrameRate::new(100, i64::from(step)), + duration: Tick::from_units(total, 100), + has_video: true, + has_audio: false, + alpha: AlphaMode::Straight, + codec: CODEC.to_string(), + ..Default::default() + }) +} + +/// Every frame of a GIF, composited onto the full canvas. +pub(crate) struct Anim { + frames: Vec, + /// When each frame starts, in hundredths of a second. + starts: Vec, +} + +impl Anim { + /// Decode all frames. A damaged tail keeps the frames before it. + pub(crate) fn load(bytes: &[u8]) -> Result { + let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + let (mut frames, mut starts) = (Vec::new(), Vec::new()); + let (mut at, mut size) = (0u64, 0usize); + for frame in dec.into_frames() { + let frame = match frame { + Ok(f) => f, + Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("GIF: {e}"))), + Err(e) => { + log::warn!("media: GIF stops after {} frames: {e}", frames.len()); + break; + } + }; + let (numer, denom) = frame.delay().numer_denom_ms(); + let cs = delay_cs(numer.checked_div(denom).unwrap_or(0) / 10); + let buf = frame.into_buffer(); + size = size.saturating_add(buf.as_raw().len()); + if size > MAX_BYTES { + return Err(MediaError::Unsupported("GIF is too large to hold in memory".into())); + } + starts.push(at); + at = at.saturating_add(u64::from(cs)); + frames.push(DynamicImage::ImageRgba8(buf)); + } + if frames.is_empty() { + return Err(MediaError::Decode("GIF: no frames".into())); + } + Ok(Anim { frames, starts }) + } + + /// The frame shown at source time `t` (the last one past the end). + pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> { + let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128; + let cs = u64::try_from(cs).unwrap_or(u64::MAX); + let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1); + self.frames.get(i).or(self.frames.last()) + } +} + +#[cfg(test)] +mod tests { + use effectcraft_project::{Footage, FootageKind}; + use effectcraft_time::{FrameRate, Tick}; + + use crate::{MediaPool, probe_bytes}; + + /// A 2×2 GIF with one solid-colour frame per `(rgb, delay)`. + fn gif(frames: &[([u8; 3], u16)]) -> Vec { + let mut out = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut out, 2, 2, &[]).unwrap(); + for (rgb, delay) in frames { + let mut f = gif::Frame::from_rgb(2, 2, &rgb.repeat(4)); + f.delay = *delay; + enc.write_frame(&f).unwrap(); + } + } + out + } + + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 255, 0]; + const BLUE: [u8; 3] = [0, 0, 255]; + + fn colour_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 3] { + let img = pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap(); + let p = img.data[0]; + [p[0], p[1], p[2]] + } + + #[test] + fn an_animated_gif_is_a_movie_with_the_files_timing() { + let bytes = gif(&[(RED, 5), (GREEN, 10), (BLUE, 5)]); + let f = probe_bytes("a.gif", bytes.clone().into()).unwrap(); + assert_eq!(f.kind, FootageKind::Video); + assert!(f.has_video && !f.has_audio); + assert_eq!((f.width, f.height), (2, 2)); + // Delays of 5, 10 and 5 hundredths: 20 frames a second, 0.2 s in all. + assert_eq!(f.frame_rate, FrameRate::new(20, 1)); + assert_eq!(f.duration, Tick::from_units(20, 100)); + + let pool = MediaPool::new(); + pool.add_bytes("a.gif", bytes.into()); + assert_eq!(colour_at(&pool, &f, 0.01), [1.0, 0.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.06), [0.0, 1.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.14), [0.0, 1.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.17), [0.0, 0.0, 1.0]); + // Past the end it holds the last frame. + assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]); + } + + #[test] + fn a_zero_delay_is_ten_hundredths_like_in_browsers() { + let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap(); + assert_eq!(f.frame_rate, FrameRate::new(10, 1)); + assert_eq!(f.duration, Tick::from_units(20, 100)); + } + + #[test] + fn a_single_frame_gif_stays_a_still() { + let f = probe_bytes("a.gif", gif(&[(RED, 10)]).into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + } + + #[test] + fn a_damaged_gif_is_an_error_not_a_panic() { + let mut bytes = gif(&[(RED, 5), (GREEN, 5)]); + bytes.truncate(20); + let pool = MediaPool::new(); + pool.add_bytes("a.gif", bytes.clone().into()); + let f = Footage { + path: "a.gif".into(), + kind: FootageKind::Video, + codec: "GIF".into(), + has_video: true, + width: 2, + height: 2, + duration: Tick::from_units(10, 100), + frame_rate: FrameRate::new(20, 1), + ..Default::default() + }; + assert!(pool.frame_at(&f, Tick::ZERO).is_err()); + let _ = probe_bytes("a.gif", bytes.into()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 64c137c9..000c1624 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -17,6 +17,7 @@ mod convert; pub mod exr_channels; +mod gif_anim; mod layered; pub use layered::vector_doc; mod pool; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 3df9ec45..5ce5730f 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -10,7 +10,8 @@ use effectcraft_render::FootageSource; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; -use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in}; +use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled}; +use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. @@ -41,6 +42,8 @@ struct Key { /// pixels. interp: u8, matte: [u32; 3], + /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). + size: (u32, u32), } struct Entry { @@ -169,6 +172,9 @@ struct CacheState { /// (`None`: failed to open; not retried until `forget`). type SourceSlot = Arc>>; +/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). +type GifSlot = Arc, String>>>; + struct Inner { /// Opened movie/audio sources by path. sources: Mutex, SourceSlot>>, @@ -178,6 +184,9 @@ struct Inner { files: Mutex>>, /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). models: Mutex>>>, + /// Decoded animated GIFs by path, loaded by the first caller while the others wait (an + /// `Err` is not retried until `forget`). + gifs: Mutex, GifSlot>>, cache: Mutex, done: Condvar, budget: AtomicU64, @@ -246,6 +255,7 @@ impl MediaPool { sources: Mutex::default(), files: Mutex::default(), models: Mutex::default(), + gifs: Mutex::default(), cache: Mutex::default(), done: Condvar::new(), budget: AtomicU64::new(bytes as u64), @@ -294,6 +304,9 @@ impl MediaPool { let mut c = lock(&self.inner.cache); c.lru = Lru::default(); c.last.clear(); + drop(c); + // Decoded GIFs sit outside the frame budget; Purge frees them too. + lock(&self.inner.gifs).clear(); } /// Drop every cached frame and decoder. @@ -306,12 +319,15 @@ impl MediaPool { pub fn forget(&self, path: &str) { lock(&self.inner.sources).remove(path); lock(&self.inner.models).remove(path); + lock(&self.inner.gifs).remove(path); let mut c = lock(&self.inner.cache); let keys: Vec = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect(); for k in keys { c.lru.remove(&k); } c.last.remove(path); + drop(c); + crate::exr_channels::forget(path); } /// Provide the contents of `path` from memory (used instead of the file system; web builds). @@ -322,7 +338,21 @@ impl MediaPool { /// Decode (or fetch from cache) the frame of `footage` at source time `t`. pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result> { - Inner::frame_at(&self.inner, footage, t, true) + Inner::frame_at(&self.inner, footage, t, true, None) + } + + /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. + fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + if footage.missing { + return None; + } + match Inner::frame_at(&self.inner, footage, t, true, size) { + Ok(img) => Some(img), + Err(e) => { + log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); + None + } + } } /// Settings ▸ Disk ▸ Conformed Audio Folder: write each footage file's decoded audio there @@ -469,7 +499,7 @@ impl MediaPool { /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result { - let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false)?; + let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?; let (w, h) = (img.width.max(1), img.height.max(1)); let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0); if s >= 1.0 { @@ -539,6 +569,20 @@ impl Inner { .clone() } + /// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the + /// map's lock; callers asking for the same file meanwhile wait for that. + fn gif(&self, path: &Arc) -> Result> { + let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); + let loaded = slot.get_or_init(|| { + let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes)); + loaded.map(Arc::new).map_err(|e| { + log::warn!("media: cannot decode {path}: {e}"); + e.to_string() + }) + }); + loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}"))) + } + /// The frame index (in the footage's interpreted rate) shown at source time `t`, after /// looping (`loop_count`) and clamping to the footage's frames; `n` = frames per loop. fn frame_index(rate: FrameRate, t: Tick, n: i64, loops: u32) -> i64 { @@ -561,7 +605,7 @@ impl Inner { }; let interp = alpha | (u8::from(footage.preserve_rgb) << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -591,21 +635,27 @@ impl Inner { } } - fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool) -> Result> { + /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that + /// size directly (see [`FootageSource::frame_at_size`]). + fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } - let loc = Self::locate(footage, t); + let mut loc = Self::locate(footage, t); let movie = loc.media_t.is_some(); + if movie && let Some(size) = size { + loc.key.size = size; + } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead if playback && movie { let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { let next_t = t + footage.frame_rate.frame_duration(); - let next = Self::locate(footage, next_t); + let mut next = Self::locate(footage, next_t); + next.key.size = loc.key.size; if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t); + self.prefetch(footage, next_t, size); } } } @@ -638,17 +688,17 @@ impl Inner { } #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false); + let _ = me.frame_at(&f, t, false, size); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {} fn decode(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage); @@ -667,9 +717,23 @@ impl Inner { }; Ok(dynamic_to_image(&img, op)) } + Some(mt) if gif_anim::is_gif(footage) => { + let anim = self.gif(path)?; + let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; + Ok(dynamic_to_image(frame, op)) + } Some(mt) => { let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?; + // A reduced-resolution render's frame, made at its size (a frame of the footage's + // own size only, as the renderer resamples only those). + let (w, h) = loc.key.size; + if w > 0 + && (vf.width, vf.height) == (footage.width, footage.height) + && let Some(img) = frame_to_image_scaled(&vf, w, h) + { + return Ok(img); + } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); Ok(frame_to_image_in(&vf, op, buf)) } @@ -693,20 +757,17 @@ impl FootageSource for MediaPool { fn purge(&self) { self.clear_frames(); } + fn forget(&self, path: &str) { + MediaPool::forget(self, path); + } fn cache_budget(&self) -> Option { Some(self.budget()) } - fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { - if footage.missing { - return None; - } - match self.frame_at(footage, t) { - Ok(img) => Some(img), - Err(e) => { - log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); - None - } - } + fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.frame_sized(item, footage, t, None) + } + fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.frame_sized(item, footage, t, Some((width, height))) } fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { @@ -834,7 +895,7 @@ mod tests { fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3] }; + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), budget); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 72e25699..4e986151 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -65,6 +65,12 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { return probe_model(path, &bytes, &|_| None); } if let Ok(fmt) = image::guess_format(&bytes) { + // An animated GIF is a movie; one with a single frame is a still. + if fmt == image::ImageFormat::Gif + && let Some(f) = crate::gif_anim::probe(path, &bytes) + { + return Ok(f); + } return probe_still_bytes(path, &bytes, fmt); } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index ed113c35..d26b1a1d 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -236,6 +236,29 @@ fn h264_mp4_probe_and_frames() { assert!(Arc::ptr_eq(&a, &b)); } +/// A reduced-resolution render's movie frames are converted at its size: they match resampling +/// the full frames (testsrc2's saturated colours clip, where the two differ most), and are cached +/// per size. +#[test] +fn h264_frames_at_reduced_sizes() { + let Some(p) = h264_mp4() else { return }; + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + for (n, (w, h)) in [(0i64, (960, 540)), (1, (640, 360)), (2, (480, 270)), (30, (323, 182))] { + let t = t_of(f.frame_rate, n); + let sized = pool.frame_at_size(ItemId(1), &f, t, w, h).expect("frame"); + let full = pool.frame(ItemId(1), &f, t).expect("frame"); + assert_eq!((sized.width, sized.height), (w, h)); + let db = psnr(&sized, &effectcraft_raster::resample(&full, w, h)); + eprintln!("h264 frame {n} at {w}×{h}: {db:.2} dB against the resampled full frame"); + // Converting at half size averages before the YUV→RGB clip, so saturated pixels differ + // slightly; how much depends on the encoder that made the fixture (50 dB with x264 from + // ffmpeg 6.1, above 55 with newer ones), all well past visible. + assert!(db > 45.0, "frame {n} at {w}×{h}: {db:.2} dB"); + assert!(Arc::ptr_eq(&sized, &pool.frame_at_size(ItemId(1), &f, t, w, h).expect("frame")), "cached"); + } +} + #[test] fn h264_audio_samples() { let Some(p) = h264_mp4() else { return }; diff --git a/crates/pdf/src/ccitt.rs b/crates/pdf/src/ccitt.rs index ebd4cad7..fa013161 100644 --- a/crates/pdf/src/ccitt.rs +++ b/crates/pdf/src/ccitt.rs @@ -493,10 +493,16 @@ fn pack(changes: &[usize], columns: usize, black_is_1: bool, out: &mut Vec) } /// Decode CCITT fax data. Damaged data ends the image at the last good row (rows already -/// decoded are kept); `None` only when no row decodes. -pub fn decode(data: &[u8], p: &Params) -> Option> { +/// decoded are kept); `None` when no row decodes or the output would exceed `max`. +pub fn decode(data: &[u8], p: &Params, max: usize) -> Option> { let columns = p.columns.clamp(1, 1 << 16); let max_rows = if p.rows > 0 { p.rows.min(1 << 20) } else { 1 << 20 }; + let row_bytes = columns.checked_add(7)? / 8; + // With `/Rows` the image is padded to that many rows (below), so its size is known up front. + let padded_len = if p.rows > 0 { max_rows.checked_mul(row_bytes)? } else { 0 }; + if row_bytes > max || padded_len > max { + return None; + } let mut r = Reader { data, bit: 0 }; let mut out = vec![]; let mut refl: Vec = vec![]; @@ -530,14 +536,17 @@ pub fn decode(data: &[u8], p: &Params) -> Option> { if two_d { row_2d(&mut r, &refl, columns).ok() } else { row_1d(&mut r, columns) } }; let Some(line) = line else { break }; + // Unknown row counts are bounded as rows arrive, without returning a prefix. + if out.len().checked_add(row_bytes)? > max { + return None; + } pack(&line, columns, p.black_is_1, &mut out); refl = line; rows += 1; } // Rows missing from damaged or short data stay white. if p.rows > 0 && rows > 0 && rows < p.rows { - let row_bytes = columns.div_ceil(8); - out.resize(p.rows.min(1 << 20) * row_bytes, if p.black_is_1 { 0 } else { 0xFF }); + out.resize(padded_len, if p.black_is_1 { 0 } else { 0xFF }); } (rows > 0).then_some(out) } @@ -726,14 +735,14 @@ pub(crate) mod tests { for k in [-1, 0, 1, 3] { let data = encode(&img, k); let p = Params { k, columns: cols, rows, ..Params::default() }; - let out = decode(&data, &p).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows")); + let out = decode(&data, &p, 1 << 20).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows")); assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}"); // Rows unknown: stop at the end of the data / EOFB. - let out = decode(&data, &Params { rows: 0, ..p.clone() }).unwrap(); + let out = decode(&data, &Params { rows: 0, ..p.clone() }, 1 << 20).unwrap(); assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}, no /Rows"); // BlackIs1 inverts. - let inv = decode(&data, &Params { black_is_1: true, ..p }).unwrap(); - assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }).unwrap()).all(|(a, b)| a ^ b == 0xFF)); + let inv = decode(&data, &Params { black_is_1: true, ..p }, 1 << 20).unwrap(); + assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }, 1 << 20).unwrap()).all(|(a, b)| a ^ b == 0xFF)); } } } @@ -769,9 +778,9 @@ pub(crate) mod tests { ]; for (i, (k, data)) in cases.iter().enumerate() { let p = Params { k: *k, columns: 61, rows: 23, black_is_1: true, end_of_line: *k >= 0, ..Params::default() }; - let out = decode(&hex(data), &p).unwrap(); + let out = decode(&hex(data), &p, 1 << 20).unwrap(); assert_eq!(out, raw, "case {i} (K {k})"); - let out = decode(&hex(data), &Params { rows: 0, ..p }).unwrap(); + let out = decode(&hex(data), &Params { rows: 0, ..p }, 1 << 20).unwrap(); assert_eq!(out, raw, "case {i} (K {k}), no /Rows"); } } @@ -781,7 +790,7 @@ pub(crate) mod tests { let img = pattern(64, 20, 9); let data = encode(&img, -1); for cut in [0, 1, data.len() / 3, data.len() - 3] { - let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() }); + let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() }, 1 << 20); if let Some(out) = out { assert_eq!(out.len(), 20 * 8); } @@ -791,8 +800,8 @@ pub(crate) mod tests { *b ^= (i * 37) as u8; } for k in [-1, 0, 2] { - let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() }); - let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }); + let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() }, 1 << 20); + let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20); } } } diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index d450ea0d..019121e0 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -1,6 +1,9 @@ //! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, //! exponential, stitching and PostScript calculator functions). +use std::collections::HashMap; +use std::sync::Arc; + use crate::object::{Dict, File, Obj, decode_stream}; #[derive(Clone, Debug, PartialEq)] @@ -79,9 +82,23 @@ fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] { }) } +/// How deep colour spaces nest (an Indexed base, the alternate of an ICCBased, Separation or +/// DeviceN space) and, counted separately, functions (in arrays and stitching functions). +/// References can make either a cycle, while real files nest two or three levels; anything deeper +/// reads as unreadable (DeviceGray, an unsupported function). +const MAX_DEPTH: usize = 8; + /// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are /// looked up in `res` (`/ColorSpace`). pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { + color_space_at(file, o, res, 0) +} + +/// [`color_space`] nested `depth` levels deep: DeviceGray past [`MAX_DEPTH`]. +fn color_space_at(file: &File, o: &Obj, res: Option<&Dict>, depth: usize) -> Cs { + if depth >= MAX_DEPTH { + return Cs::Gray; + } let o = file.resolve(o); match o { Obj::Name(n) => match n.as_str() { @@ -90,7 +107,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { "DeviceCMYK" | "CMYK" => Cs::Cmyk, "Pattern" => Cs::Pattern, other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) { - Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None), + Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space_at(file, x, None, depth + 1), _ => Cs::Gray, }, }, @@ -102,7 +119,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { // The profile's alternate space when it names one (profiles are not // evaluated), else the device space with the same number of components. if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) { - return color_space(file, alt, res); + return color_space_at(file, alt, res, depth + 1); } let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; match n { @@ -116,8 +133,10 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { "Lab" => Cs::Lab, "Pattern" => Cs::Pattern, "Indexed" | "I" => { - let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb); - let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize; + let base = a.get(1).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Rgb); + // An indexed palette holds at most 256 entries, so hival (its largest index) is + // 0 to 255; an image builds its palette from every entry up to hival. + let hival = (a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize).min(255); let lookup = match a.get(3).map(|l| file.resolve(l)) { Some(Obj::Str(s)) => s.clone(), Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(), @@ -127,7 +146,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { } "Separation" | "DeviceN" => { let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) }; - let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray); + let alt = a.get(2).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Gray); let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported); // An unreadable tint transform: tint 1 = black, 0 = white. if func == Func::Unsupported { @@ -158,14 +177,14 @@ pub enum Func { bounds: Vec, encode: Vec, }, - /// Type 0 with one input. + /// Type 0 with one input. Copies share the samples (a graphics state is copied at every `q`). Sampled { domain: [f64; 2], size: usize, outputs: usize, encode: [f64; 2], decode: Vec, - samples: Vec, + samples: Arc<[f64]>, }, /// Type 0 with several inputs (function-based shadings): multilinear interpolation, the /// first input varying fastest in the sample table. @@ -175,7 +194,7 @@ pub enum Func { outputs: usize, encode: Vec, decode: Vec, - samples: Vec, + samples: Arc<[f64]>, }, /// An array of one-output functions. Array(Vec), @@ -215,6 +234,17 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { out } +/// A calculator function's operand stack holds at most 100 values (the PDF specification's +/// implementation limits); a real program uses a handful. +const MAX_STACK: usize = 100; + +/// Push onto a calculator's operand stack; a full stack takes nothing more. +fn ps_push(st: &mut Vec, v: f64) { + if st.len() < MAX_STACK { + st.push(v); + } +} + fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { if depth > 32 { return; @@ -222,7 +252,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { let mut procs: Vec<&[PsOp]> = vec![]; for op in code { match op { - PsOp::Num(n) => st.push(*n), + PsOp::Num(n) => ps_push(st, *n), PsOp::Proc(p) => procs.push(p), PsOp::Op(o) => { let pop = |st: &mut Vec| st.pop().unwrap_or(0.0); @@ -243,14 +273,14 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { ps_run(p, st, depth + 1); } } - "true" => st.push(1.0), - "false" => st.push(0.0), + "true" => ps_push(st, 1.0), + "false" => ps_push(st, 0.0), "pop" => { st.pop(); } "dup" => { let v = st.last().copied().unwrap_or(0.0); - st.push(v); + ps_push(st, v); } "exch" => { let n = st.len(); @@ -261,15 +291,16 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { "copy" => { let k = pop(st).max(0.0) as usize; let n = st.len(); - if k <= n { - let tail = st[n - k..].to_vec(); + // A copy that would pass the cap copies nothing. + if k <= n && n + k <= MAX_STACK { + let tail = st.get(n - k..).unwrap_or_default().to_vec(); st.extend(tail); } } "index" => { let k = pop(st).max(0.0) as usize; - let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0); - st.push(v); + let v = k.checked_add(1).and_then(|k| st.len().checked_sub(k)).and_then(|i| st.get(i)).copied().unwrap_or(0.0); + ps_push(st, v); } "roll" => { let j = pop(st) as i64; @@ -309,7 +340,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { }; if let Some(f) = unary { let x = pop(st); - st.push(f(x)); + ps_push(st, f(x)); continue; } let y = pop(st); @@ -325,20 +356,10 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { 0.0 } } - "idiv" => { - if y as i64 != 0 { - ((x as i64) / (y as i64)) as f64 - } else { - 0.0 - } - } - "mod" => { - if y as i64 != 0 { - ((x as i64) % (y as i64)) as f64 - } else { - 0.0 - } - } + // `checked_*` give 0 both for a zero divisor and for the one overflowing + // case, `i64::MIN / -1` (`/` and `%` panic on it in every build). + "idiv" => (x as i64).checked_div(y as i64).unwrap_or(0) as f64, + "mod" => (x as i64).checked_rem(y as i64).unwrap_or(0) as f64, "exp" => x.powf(y), "atan" => { let a = x.atan2(y).to_degrees(); @@ -358,11 +379,11 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { (if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64 } _ => { - st.push(x); + ps_push(st, x); y } }; - st.push(v); + ps_push(st, v); } } } @@ -370,11 +391,111 @@ fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { } } +/// The most `f64`s one sampled table holds: 16 Mi (128 MiB), room for a 33 × 33 × 33 × 33 grid +/// with four outputs (4_743_684 samples). One table this large is allowed; what stops a file from +/// building many of them is [`Budget`], which caps the samples decoded across a whole +/// [`Func::read`] at the same figure. +const MAX_SAMPLES: usize = 1 << 24; + +/// The most inputs or outputs of a function: no colour space has more than 32 components (the most +/// a DeviceN space may have), so a sampled function needs no more. Also the most one-output +/// functions a [`Func::Array`] holds (one per output). It is NOT a limit on the pieces of a +/// stitching function — a repeating gradient lists one piece once per repeat, often many more. +const MAX_IO: usize = 32; + +/// The most `Func` nodes one top-level [`Func::read`] builds, across the whole structure of +/// references. A real function is a handful of nodes; a cyclic or fan-out reference graph would +/// build far more, so past this the whole function is unreadable. It is a soft limit, and the only +/// one on the pieces of a stitching function: past it a colour space takes its fallback (a +/// Separation tint reads as grey), nothing unsafe. +const MAX_NODES: usize = 10_000; + +/// Samples in a table of `total` entries × `outputs`; `None` when that overflows or passes +/// [`MAX_SAMPLES`]. +fn sample_count(total: usize, outputs: usize) -> Option { + total.checked_mul(outputs).filter(|n| *n <= MAX_SAMPLES) +} + +/// What one top-level [`Func::read`] may build before the whole function is treated as unreadable: +/// a running cap on `Func` nodes and on sampled-table samples across the entire structure, so a +/// reference graph that is cyclic or fans out (one object listed many times at each of several +/// levels) cannot build an unbounded tree or decode unbounded memory. Samples of a table shared by +/// repeat references (one `Arc`) are charged once; its nodes are charged per copy. +struct Budget { + nodes: usize, + samples: usize, + /// Set once either cap is passed; the top-level read then yields [`Func::Unsupported`]. + overrun: bool, +} + +impl Budget { + fn new() -> Self { + Budget { nodes: MAX_NODES, samples: MAX_SAMPLES, overrun: false } + } + + /// Charge `n` nodes; `false` (and the read is abandoned) once the cap is passed. + fn take_nodes(&mut self, n: usize) -> bool { + match self.nodes.checked_sub(n) { + Some(left) => { + self.nodes = left; + true + } + None => { + self.overrun = true; + false + } + } + } + + /// Charge `n` samples; `false` once the cap is passed. + fn take_samples(&mut self, n: usize) -> bool { + match self.samples.checked_sub(n) { + Some(left) => { + self.samples = left; + true + } + None => { + self.overrun = true; + false + } + } + } +} + +/// `v` limited to the range between `a` and `b`, in either order. Unlike [`f64::clamp`] this never +/// panics, whether a file gives the bounds reversed (`/Domain [1 0]`) or a bound is NaN. +fn clamp(v: f64, a: f64, b: f64) -> f64 { + let (lo, hi) = (a.min(b), a.max(b)); + if v < lo { + lo + } else if v > hi { + hi + } else { + v + } +} + impl Func { pub fn read(file: &File, o: &Obj) -> Func { + let mut budget = Budget::new(); + let f = Func::read_at(file, o, 0, &mut budget); + // Either cap passed anywhere in the structure: the whole function is unreadable. + if budget.overrun { Func::Unsupported } else { f } + } + + /// [`Func::read`] nested `depth` levels deep, charging `budget`: unsupported past [`MAX_DEPTH`] + /// or once the budget is spent. + fn read_at(file: &File, o: &Obj, depth: usize, budget: &mut Budget) -> Func { + if depth >= MAX_DEPTH || !budget.take_nodes(1) { + return Func::Unsupported; + } let o = file.resolve(o); if let Obj::Array(a) = o { - return Func::Array(a.iter().map(|f| Func::read(file, f)).collect()); + // An array of one-output functions: one per output, so at most `MAX_IO`. + if a.len() > MAX_IO { + return Func::Unsupported; + } + return Func::read_all(file, a, depth + 1, budget).map_or(Func::Unsupported, Func::Array); } let Some(d) = o.dict() else { return Func::Unsupported }; let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default(); @@ -386,12 +507,21 @@ impl Func { c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]), n: file.get_num(d, "N").unwrap_or(1.0), }, - 3 => Func::Stitch { - domain, - funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(), - bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), - encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), - }, + 3 => { + // A stitching function's pieces: no element cap (a repeating gradient lists one + // piece once per repeat); the budget bounds the total work. + let funcs = match file.get(d, "Functions").and_then(Obj::array) { + Some(a) => Func::read_all(file, a, depth + 1, budget), + None => Some(vec![]), + }; + let Some(funcs) = funcs else { return Func::Unsupported }; + Func::Stitch { + domain, + funcs, + bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(), + encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(), + } + } 0 => { let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; @@ -400,26 +530,31 @@ impl Func { let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize; let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(); let outputs = range.len() / 2; - if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) { + if size == 0 || outputs == 0 || outputs > MAX_IO || sizes.len() > MAX_IO || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) { return Func::Unsupported; } let total = sizes.iter().try_fold(1usize, |a, b| a.checked_mul(*b)).filter(|t| *t > 0 && *t <= 1 << 22); let Some(total) = total else { return Func::Unsupported }; + let Some(count) = sample_count(total, outputs) else { return Func::Unsupported }; + if !budget.take_samples(count) { + return Func::Unsupported; + } let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(); let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)]; let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone()); let max = ((1u64 << bps) - 1) as f64; - let mut samples = Vec::with_capacity(size * outputs); let mut bit = 0usize; - for _ in 0..total * outputs { - let mut v = 0u64; - for _ in 0..bps { - let byte = data.get(bit / 8).copied().unwrap_or(0); - v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64; - bit += 1; - } - samples.push(v as f64 / max); - } + let samples: Arc<[f64]> = (0..count) + .map(|_| { + let mut v = 0u64; + for _ in 0..bps { + let byte = data.get(bit / 8).copied().unwrap_or(0); + v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64; + bit += 1; + } + v as f64 / max + }) + .collect(); if sizes.len() > 1 { let m = sizes.len(); let encode = @@ -444,22 +579,59 @@ impl Func { } } + /// The functions of an array (of one-output functions, or a stitching function's + /// `/Functions`) read at `depth`, charging `budget`; `None` once the budget is spent. An object + /// the array refers to more than once is read once and copied: the copies share its samples + /// (one `Arc`, charged once), but each copy is charged its nodes again — so a reference listed + /// many times at each of several levels spends the node budget quickly rather than expanding. + fn read_all(file: &File, a: &[Obj], depth: usize, budget: &mut Budget) -> Option> { + let mut cache: HashMap<(u32, u16), (Func, usize)> = HashMap::new(); + let mut funcs = Vec::with_capacity(a.len()); + for o in a { + if budget.overrun { + return None; + } + let f = match o { + Obj::Ref(n, g) => { + if let Some((cached, cost)) = cache.get(&(*n, *g)) { + let (cached, cost) = (cached.clone(), *cost); + if !budget.take_nodes(cost) { + return None; + } + cached + } else { + let before = budget.nodes; + let f = Func::read_at(file, o, depth, budget); + let cost = before.saturating_sub(budget.nodes); + cache.insert((*n, *g), (f.clone(), cost)); + f + } + } + _ => Func::read_at(file, o, depth, budget), + }; + funcs.push(f); + } + Some(funcs) + } + /// Evaluate at the first input (the functions used by shadings and tints take one input /// in practice; extra inputs of DeviceN tints are averaged in). pub fn eval(&self, input: &[f64]) -> Vec { if let Func::Calc { domain, range, code } = self { + // The inputs start the operand stack, which holds at most `MAX_STACK` values. let mut st: Vec = input .iter() + .take(MAX_STACK) .enumerate() .map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) { - (Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)), + (Some(a), Some(b)) => clamp(*v, *a, *b), _ => *v, }) .collect(); ps_run(code, &mut st, 0); let n = range.len() / 2; let k = st.len().saturating_sub(n); - return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect(); + return st.get(k..).unwrap_or_default().iter().zip(range.as_chunks::<2>().0).map(|(v, [a, b])| clamp(*v, *a, *b)).collect(); } match self { Func::SampledN { domain, size, outputs, encode, decode, samples } => { @@ -470,9 +642,9 @@ impl Func { for i in 0..m { let x = input.get(i).copied().unwrap_or(0.0); let (d0, d1) = (domain[2 * i], domain[2 * i + 1]); - let x = x.clamp(d0.min(d1), d0.max(d1)); + let x = clamp(x, d0, d1); let e = if d1 != d0 { encode[2 * i] + (x - d0) / (d1 - d0) * (encode[2 * i + 1] - encode[2 * i]) } else { encode[2 * i] }; - let e = if e.is_finite() { e.clamp(0.0, size[i] as f64 - 1.0) } else { 0.0 }; + let e = if e.is_finite() { clamp(e, 0.0, size[i] as f64 - 1.0) } else { 0.0 }; base[i] = e.floor() as usize; frac[i] = e - e.floor(); } @@ -510,7 +682,7 @@ impl Func { pub fn eval1(&self, t: f64) -> Vec { match self { Func::Exp { domain, c0, c1, n } => { - let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1])); + let t = clamp(t, domain[0], domain[1]); let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) }; (0..c0.len().max(c1.len())) .map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0))) @@ -520,7 +692,7 @@ impl Func { if funcs.is_empty() { return vec![0.0]; } - let t = t.clamp(domain[0], domain[1]); + let t = clamp(t, domain[0], domain[1]); let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1); let lo = if k == 0 { domain[0] } else { bounds[k - 1] }; let hi = bounds.get(k).copied().unwrap_or(domain[1]); @@ -529,9 +701,9 @@ impl Func { funcs[k].eval1(u) } Func::Sampled { domain, size, outputs, encode, decode, samples } => { - let t = t.clamp(domain[0], domain[1]); + let t = clamp(t, domain[0], domain[1]); let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] }; - let e = e.clamp(0.0, *size as f64 - 1.0); + let e = clamp(e, 0.0, *size as f64 - 1.0); let (i0, f) = (e.floor() as usize, e.fract()); let i1 = (i0 + 1).min(size - 1); (0..*outputs) @@ -573,6 +745,19 @@ pub fn text_string(b: &[u8]) -> String { mod tests { use super::*; + #[test] + fn sample_count_at_and_over_the_cap() { + // 4 Mi entries × 4 outputs fill the cap exactly; a fifth output passes it. + assert_eq!(sample_count(1 << 22, 4), Some(MAX_SAMPLES)); + assert_eq!(sample_count(1 << 22, 5), None); + // A 33 × 33 × 33 × 33 grid with four outputs fits (4_743_684 samples, under the cap, so + // `Some`). + assert_eq!(sample_count(33 * 33 * 33 * 33, 4), Some(4_743_684)); + // A product that overflows is refused (on 64-bit targets that takes a `/Range` of at least + // 2^41 numbers, too big to build in a test). + assert_eq!(sample_count(1 << 22, usize::MAX / (1 << 22) + 1), None); + } + #[test] fn calculator_functions() { // Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 } @@ -583,4 +768,62 @@ mod tests { assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]); assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]); } + + /// Run the calculator program `{ src }` on the stack `st`. + fn run(src: &str, mut st: Vec) -> Vec { + let src = format!("{{ {src} }}"); + let mut lx = crate::object::Lexer::new(src.as_bytes(), 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + ps_run(&code, &mut st, 0); + st + } + + #[test] + fn calculator_stack_holds_at_most_100_values() { + // `1 copy 2 copy 4 copy …`: 40 doublings asked for 2^40 values (8 TiB). The copies stop at + // 64 values, since the next would pass 100. + let doublings: String = (0..40).map(|i| format!("{} copy ", 1u64 << i)).collect(); + assert_eq!(run(&doublings, vec![0.0]).len(), 64); + // A copy up to exactly 100 values is made; after it nothing grows the stack. + let fifty: Vec = (0..50).map(f64::from).collect(); + let full = run("50 copy", fifty.clone()); + assert_eq!(full.len(), 100); + assert_eq!(full[50..], fifty[..]); + for op in ["7", "true", "false", "dup", "0 index", "1 copy", "neg", "add", "nonsense"] { + assert!(run(op, full.clone()).len() <= 100, "{op}"); + } + // Nor does a long program without loops: one push per operator, up to the cap. + for op in ["1 ", "true ", "dup ", "0 index "] { + assert_eq!(run(&op.repeat(1000), vec![1.0]).len(), 100, "{op}"); + } + } + + #[test] + fn calculator_index_past_the_stack_pushes_zero() { + // 10^20 is past usize::MAX: `k + 1` overflowed (a panic), or wrapped to read past the stack. + assert_eq!(run("99999999999999999999 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]); + assert_eq!(run("2 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]); + assert_eq!(run("1 index", vec![1.0, 2.0]), vec![1.0, 2.0, 1.0]); + } + + #[test] + fn calculator_copy_index_and_roll_below_the_cap() { + assert_eq!(run("3 copy 1 index 4 1 roll", vec![1.0, 2.0, 3.0]), vec![1.0, 2.0, 3.0, 2.0, 1.0, 2.0, 3.0]); + assert_eq!(run("3 -1 roll dup", vec![1.0, 2.0, 3.0]), vec![2.0, 3.0, 1.0, 1.0]); + } + + #[test] + fn calculator_idiv_and_mod_of_i64_min_by_minus_one() { + // `i64::MIN / -1` and `i64::MIN % -1` overflow and panic (in debug and release); `idiv` and + // `mod` must fall back like a zero divisor does. -2^63 is below any f64 the program sees, + // but the cast saturates to i64::MIN, which is enough to trigger it. + let min = -(2f64.powi(63)); + assert_eq!(run("idiv", vec![min, -1.0]), vec![0.0]); + assert_eq!(run("mod", vec![min, -1.0]), vec![0.0]); + // Divide by zero still gives 0, ordinary division still works. + assert_eq!(run("idiv", vec![7.0, 0.0]), vec![0.0]); + assert_eq!(run("mod", vec![7.0, 0.0]), vec![0.0]); + assert_eq!(run("idiv", vec![7.0, 2.0]), vec![3.0]); + assert_eq!(run("mod", vec![7.0, 2.0]), vec![1.0]); + } } diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index ffd9f9bc..b9016222 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -304,9 +304,29 @@ pub fn parse_num(w: &[u8]) -> Option { // ------------------------------------------------------------------ filters +/// The most bytes one stream filter may decode to. Deflate expands up to about 1000:1, so +/// unbounded, a few MB of PDF ask for gigabytes and a failed allocation aborts the process. The +/// largest image `image::decode` accepts (64 Mi pixels) of four 16-bit components is 512 MiB; +/// 1 GiB leaves room for more components and is PdfCraft's ceiling for one stream. 32-bit +/// targets (the web build) get a quarter: their whole address space is 4 GiB, and the inflater's +/// growing buffer can reserve about twice the limit. +const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 }; + +/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them, +/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and +/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and +/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above. +const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 }; + /// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, /// JBIG2) give `None`. pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { + decode_stream_within(file, d, raw, MAX_INFLATED) +} + +/// [`decode_stream`] with every filter's output bounded by `max` bytes. Exceeding the +/// bound gives `None`, never a truncated prefix; predictors share the same bound. +pub fn decode_stream_within(file: &File, d: &Dict, raw: &[u8], max: usize) -> Option> { let filters: Vec = match d.get("Filter").map(|f| file.resolve(f)) { None => vec![], Some(Obj::Name(n)) => vec![n.clone()], @@ -321,20 +341,23 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { let mut data = raw.to_vec(); for (i, f) in filters.iter().enumerate() { data = match f.as_str() { - "FlateDecode" | "Fl" => inflate(&data)?, - "ASCIIHexDecode" | "AHx" => ascii_hex(&data), - "ASCII85Decode" | "A85" => ascii85(&data), + "FlateDecode" | "Fl" => inflate(&data, max)?, + "ASCIIHexDecode" | "AHx" => ascii_hex(&data, max)?, + "ASCII85Decode" | "A85" => ascii85_within(&data, max)?, "LZWDecode" | "LZW" => { - lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0) + lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0, max)? } - "RunLengthDecode" | "RL" => run_length(&data), - "CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())))?, + "RunLengthDecode" | "RL" => run_length(&data, max)?, + "CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())), max)?, _ => return None, }; + if data.len() > max { + return None; + } if let Some(Some(p)) = parms.get(i) && matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW") { - data = predict(&data, p); + data = predict(&data, p, max)?; } } Some(data) @@ -358,25 +381,60 @@ fn ccitt_params(file: &File, d: Option<&Dict>) -> crate::ccitt::Params { p } -pub fn inflate(data: &[u8]) -> Option> { - miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { - // Truncated streams: keep what inflates. - let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); - let mut out = vec![0u8; data.len() * 8 + 1024]; - let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); - (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) - }) +/// Inflate zlib data (or raw deflate) to at most `max` bytes. A stream that inflates to more is +/// undecodable (`None`), not salvaged as a truncated one. +pub fn inflate(data: &[u8], max: usize) -> Option> { + use miniz_oxide::inflate::{TINFLStatus, decompress_to_vec_with_limit, decompress_to_vec_zlib_with_limit}; + for attempt in [decompress_to_vec_zlib_with_limit, decompress_to_vec_with_limit] { + match attempt(data, max) { + Ok(v) => return Some(v), + Err(e) if e.status == TINFLStatus::HasMoreOutput => return None, + Err(_) => {} + } + } + // Truncated streams: keep what inflates. + let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); + let mut out = vec![0u8; salvage_len(data.len(), max)]; + let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); + (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) } -fn ascii_hex(data: &[u8]) -> Vec { - let mut hex: Vec = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect(); - if hex.len() % 2 == 1 { - hex.push(b'0'); +/// The buffer [`inflate`] salvages a truncated stream of `data_len` bytes into: eight times that +/// and 1 KiB, at most `max`. The zlib attempt already stopped within `max`, so the bound loses +/// nothing, while without it a Flate filter after another could ask for eight times `max`. +pub fn salvage_len(data_len: usize, max: usize) -> usize { + data_len.saturating_mul(8).saturating_add(1024).min(max) +} + +fn ascii_hex(data: &[u8], max: usize) -> Option> { + let mut out = vec![]; + let mut high = None; + for c in data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit) { + if let Some(h) = high.take() { + if out.len() >= max { + return None; + } + out.push((h << 4) | hexv(c)); + } else { + high = Some(hexv(c)); + } } - hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() + if let Some(h) = high { + if out.len() >= max { + return None; + } + out.push(h << 4); + } + Some(out) } pub fn ascii85(data: &[u8]) -> Vec { + // The PostScript reader uses this unbounded entry point. No Vec can hold more + // than usize::MAX bytes, so its output cannot exceed this bound. + ascii85_within(data, usize::MAX).unwrap_or_default() +} + +fn ascii85_within(data: &[u8], max: usize) -> Option> { let mut out = vec![]; let mut group = [0u32; 5]; let mut n = 0; @@ -389,11 +447,19 @@ pub fn ascii85(data: &[u8]) -> Vec { i += 1; match c { b'~' => break, - b'z' if n == 0 => out.extend_from_slice(&[0; 4]), + b'z' if n == 0 => { + if out.len().checked_add(4)? > max { + return None; + } + out.extend_from_slice(&[0; 4]); + } b'!'..=b'u' => { group[n] = (c - b'!') as u32; n += 1; if n == 5 { + if out.len().checked_add(4)? > max { + return None; + } let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); out.extend_from_slice(&v.to_be_bytes()); n = 0; @@ -403,16 +469,19 @@ pub fn ascii85(data: &[u8]) -> Vec { } } if n > 1 { + if out.len().checked_add(n - 1)? > max { + return None; + } for g in group.iter_mut().skip(n) { *g = 84; } let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d)); out.extend_from_slice(&v.to_be_bytes()[..n - 1]); } - out + Some(out) } -fn lzw(data: &[u8], early: bool) -> Vec { +fn lzw(data: &[u8], early: bool, max: usize) -> Option> { let mut out = vec![]; let mut table: Vec> = (0..=255u16).map(|b| vec![b as u8]).collect(); table.push(vec![]); @@ -432,7 +501,7 @@ fn lzw(data: &[u8], early: bool) -> Vec { prev = None; continue; } - 257 => return out, + 257 => return Some(out), _ => {} } let entry = if code < table.len() { @@ -442,10 +511,16 @@ fn lzw(data: &[u8], early: bool) -> Vec { e.push(p[0]); e } else { - return out; + return Some(out); }; + if out.len().checked_add(entry.len())? > max { + return None; + } out.extend_from_slice(&entry); - if let Some(p) = prev { + // PDF codes have at most 12 bits. Freeze a full table until a clear code. + if let Some(p) = prev + && table.len() < 4096 + { let mut e = p; e.push(entry[0]); table.push(e); @@ -457,43 +532,61 @@ fn lzw(data: &[u8], early: bool) -> Vec { } } } - out + Some(out) } -fn run_length(data: &[u8]) -> Vec { +fn run_length(data: &[u8], max: usize) -> Option> { let mut out = vec![]; let mut i = 0; while i < data.len() { let l = data[i] as usize; i += 1; if l < 128 { - out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]); - i += l + 1; + let end = i.saturating_add(l + 1).min(data.len()); + let literal = data.get(i..end)?; + if out.len().checked_add(literal.len())? > max { + return None; + } + out.extend_from_slice(literal); + i = end; } else if l > 128 { if let Some(&b) = data.get(i) { + if out.len().checked_add(257 - l)? > max { + return None; + } out.extend(std::iter::repeat_n(b, 257 - l)); } - i += 1; + i = i.saturating_add(1); } else { break; } } - out + Some(out) } /// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`). -fn predict(data: &[u8], p: &Dict) -> Vec { +fn predict(data: &[u8], p: &Dict, max: usize) -> Option> { + if data.len() > max { + return None; + } let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize; let pred = g("Predictor", 1.0); if pred < 10 { - return data.to_vec(); + return Some(data.to_vec()); } - let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1); - let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8); + // A row that is zero bytes wide, overflows, or is not shorter than the data leaves no + // complete row: nothing comes out, as it always did for the first and last, and the row + // buffer (sized from `/Columns`) is not allocated. + let Some(pixel_bits) = g("Colors", 1.0).checked_mul(g("BitsPerComponent", 8.0)) else { return Some(vec![]) }; + let Some(bpp) = pixel_bits.checked_add(7).map(|n| (n / 8).max(1)) else { return Some(vec![]) }; + let Some(row) = g("Columns", 1.0).checked_mul(pixel_bits).and_then(|n| n.checked_add(7)).map(|n| n / 8).filter(|&n| n > 0 && n < data.len()) else { + return Some(vec![]); + }; + let stride = row.checked_add(1)?; let mut out = Vec::with_capacity(data.len()); let mut prev = vec![0u8; row]; - for chunk in data.chunks(row + 1) { - if chunk.len() < row + 1 { + for chunk in data.chunks(stride) { + if chunk.len() < stride { break; } let ft = chunk[0]; @@ -524,7 +617,7 @@ fn predict(data: &[u8], p: &Dict) -> Vec { out.extend_from_slice(&cur); prev = cur; } - out + Some(out) } // ------------------------------------------------------------------ the file @@ -655,7 +748,7 @@ impl File { }) .collect(); for (d, raw) in streams { - let Some(dec) = decode_stream(&f, &d, &raw) else { continue }; + let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue }; let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; let mut lx = Lexer::new(&dec, 0); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index dac34bee..57b7e044 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -147,7 +147,182 @@ fn eps_postscript_subset() { fn filters_decode() { assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); - assert_eq!(object::inflate(&z).unwrap(), b"abc"); + assert_eq!(object::inflate(&z, 1 << 20).unwrap(), b"abc"); +} + +#[test] +fn flate_bomb_is_bounded() { + // A decompression bomb in miniature: 16 MiB of zeros deflate to about 16 KB. + let zeros = vec![0u8; 16 << 20]; + for bomb in [miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 6), miniz_oxide::deflate::compress_to_vec(&zeros, 6)] { + assert!(bomb.len() < 64 << 10); + // Past the limit, as zlib and as raw deflate, it is undecodable: not inflated in full + // and not salvaged as a truncated stream. + assert_eq!(object::inflate(&bomb, 1 << 20).map(|v| v.len()), None); + assert_eq!(object::inflate(&bomb, 32 << 20).map(|v| v.len()), Some(16 << 20)); + } +} + +#[test] +fn truncated_flate_keeps_what_inflates() { + let mut x = 1u32; + let data: Vec = (0..20_000) + .map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + (x >> 16) as u8 + }) + .collect(); + let z = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6); + let part = object::inflate(&z[..z.len() / 2], 1 << 20).unwrap(); + assert!(!part.is_empty() && part.len() < data.len()); + assert_eq!(part, data[..part.len()]); +} + +#[test] +fn salvage_buffer_is_bounded_by_the_limit() { + // Small inputs keep eight times their size and 1 KiB. + assert_eq!(object::salvage_len(0, 1 << 20), 1024); + assert_eq!(object::salvage_len(10_000, 1 << 20), 81_024); + // Large ones stop at the limit (`1 << 28` fits a 32-bit `usize` too), without overflowing. + assert_eq!(object::salvage_len(1 << 28, 1 << 20), 1 << 20); + assert_eq!(object::salvage_len(usize::MAX, 1 << 20), 1 << 20); +} + +#[test] +fn nested_bomb_in_the_second_flate_filter_is_refused() { + // `/Filter [/FlateDecode /FlateDecode]`: the first filter inflates, within the limit, to a zlib + // bomb (padded to just under the limit) that the second would inflate to 16 MiB. The second + // stops at the limit, so the stream is undecodable. + const MAX: usize = 1 << 20; + let flate = object::Obj::Name("FlateDecode".into()); + let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect(); + let file = object::File::parse(b""); + let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6); + nested.resize(MAX - 1, 0); + let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6); + assert_eq!(object::inflate(&raw, MAX).map(|v| v.len()), Some(MAX - 1)); + assert_eq!(object::decode_stream_within(&file, &chain, &raw, MAX).map(|v| v.len()), None); +} + +fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option> { + let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") }; + object::decode_stream_within(&object::File::parse(b""), &d, raw, max) +} + +#[test] +fn predictor_huge_columns_decode_to_nothing() { + // A row of a petabyte used to be allocated before looking at the data (an allocation + // failure aborts the process, so this test took the whole test binary down). + let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6); + let dict = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 1000000000000000 >> >>"; + assert_eq!(bounded_filter(dict, &raw, 16), Some(vec![])); +} + +#[test] +fn predictor_overflow_and_empty_rows_decode_to_nothing() { + let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6); + for parms in [ + format!("/Columns {} /Colors 2 /BitsPerComponent 8", usize::MAX), + format!("/Columns 1 /Colors {} /BitsPerComponent 8", usize::MAX), + "/Columns 0".into(), + // A row is two bytes plus its tag byte: longer than the two bytes of data. + "/Columns 2".into(), + ] { + assert_eq!(bounded_filter(&format!("<< /Filter /FlateDecode /DecodeParms << /Predictor 12 {parms} >> >>"), &raw, 16), Some(vec![])); + } +} + +#[test] +fn run_length_chain_is_bounded() { + // Each stage expands repetitions of 0x81, which is itself an RL repeat code. + let raw = [0x81, 0x81, 128]; + let chain = "<< /Filter [/RunLengthDecode /RL /RunLengthDecode] >>"; + assert_eq!(bounded_filter(chain, &raw, 128).map(|v| v.len()), None); + assert_eq!(bounded_filter("<< /Filter /RL >>", &raw, 128), Some(vec![0x81; 128])); + // An over-limit intermediate stage must fail even if the next stage shrinks it. + assert_eq!(bounded_filter("<< /Filter [/RL /AHx] >>", &raw, 127), None); +} + +fn lzw_codes(codes: impl IntoIterator, early: bool) -> Vec { + let mut w = crate::ccitt::tests::W::default(); + let (mut bits, mut entries, mut prev) = (9, 258, false); + for code in codes { + w.put(code, bits); + match code { + 256 => (bits, entries, prev) = (9, 258, false), + 257 => break, + _ => { + if prev && entries < 4096 { + entries += 1; + } + prev = true; + if entries + usize::from(early) >= (1 << bits) && bits < 12 { + bits += 1; + } + } + } + } + w.bytes +} + +#[test] +fn lzw_long_run_is_bounded_and_full_table_decodes() { + // Grow strings of A until the 12-bit table is full, then send literal A codes. + // Both old and new implementations use well under 100 MB for this fixture. + for early in [false, true] { + let raw = lzw_codes([256, 65].into_iter().chain(258..=4095).chain(std::iter::repeat_n(65, 62_500)).chain([257]), early); + assert!((95_000..105_000).contains(&raw.len())); + let dict = format!("<< /Filter /LZWDecode /DecodeParms << /EarlyChange {} >> >>", usize::from(early)); + assert_eq!(bounded_filter(&dict, &raw, 1 << 20).map(|v| v.len()), None); + let expected = 3839 * 3840 / 2 + 62_500; + assert_eq!(bounded_filter(&dict, &raw, expected), Some(vec![b'A'; expected])); + // Clear restores the initial dictionary and code width after saturation. + let cleared = lzw_codes([256, 65].into_iter().chain(258..=4095).chain([256, 66, 257]), early); + let mut expected = vec![b'A'; 3839 * 3840 / 2]; + expected.push(b'B'); + assert_eq!(bounded_filter(&dict, &cleared, expected.len()), Some(expected)); + } +} + +#[test] +fn ccitt_declared_and_unknown_rows_are_bounded() { + // Eight white Group 4 rows. The old decoder pads declared missing rows to white. + assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 /Rows 1024 >> >>", &[0xFF], 16), None); + assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 >> >>", &[0xFF], 16), None); + assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 8 >> >>", &[0xFF], 8), Some(vec![0xFF; 8])); + // Padding below the limit remains unchanged. + assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 16 >> >>", &[0xFF], 16), Some(vec![0xFF; 16])); + // Without `/Rows` the size is never computed up front: 2^20 rows of 4096 bytes overflow a + // 32-bit `usize`, but eight rows of this width decode on every target. + assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 32768 >> >>", &[0xFF], 32768), Some(vec![0xFF; 32768])); +} + +#[test] +fn ascii85_and_hex_outputs_are_bounded() { + assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 7), None); + assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 8), Some(vec![0; 8])); + assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 12), Some(b"Hello World!".to_vec())); + assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 11), None); + assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 3), None); + assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 4), Some(b"ABC@".to_vec())); +} + +#[test] +fn predictors_and_normal_lzw_decode_within_limit() { + for predictor in [1, 2, 12] { + let (data, expected) = if predictor == 12 { (vec![0, 10, 20, 2, 1, 2], vec![10, 20, 11, 22]) } else { (vec![10, 20], vec![10, 20]) }; + let raw = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6); + let dict = format!("<< /Filter /FlateDecode /DecodeParms << /Predictor {predictor} /Columns 2 >> >>"); + assert_eq!(bounded_filter(&dict, &raw, data.len()), Some(expected)); + } + for early in [false, true] { + let raw = lzw_codes([256, 65, 66, 258, 257], early); + assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 4), Some(b"ABAB".to_vec())); + assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 3), None); + } + assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 254, b'd', 128], 6), Some(b"abcddd".to_vec())); + assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 128], 2), None); + assert_eq!(bounded_filter("<< /Filter /RL >>", &[128], 0), Some(vec![])); } // ------------------------------------------------------------------ M13.6: text, images, @@ -485,6 +660,343 @@ fn tiling_patterns_coloured_and_uncoloured() { assert_eq!(at(115, 15)[3], 0.0); } +/// Object 1 of a file holding just `objs`, read as a function. +fn read_function(objs: Objs) -> color::Func { + color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0)) +} + +/// A sampled (type 0) function, read from a file holding just its stream. +fn read_sampled_function(dict: &str, data: Vec) -> color::Func { + read_function(vec![(1, dict.into(), Some(data))]) +} + +/// Object 1 of a file holding just `objs`, read as a colour space. +fn read_color_space(objs: Objs) -> color::Cs { + color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None) +} + +#[test] +fn sampled_function_over_the_sample_cap_is_unsupported() { + // 4 Mi entries, as many as `/Size` allows; every `/Range` pair adds an output, 4 Mi more samples. + let dict = |outputs: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [4194304] /Range [{}] /BitsPerSample 1 >>", "0 1 ".repeat(outputs)); + // Five outputs ask for 20 Mi samples, past the per-table cap (16 Mi); refused by the sample + // count before any decode, so this is cheap and no lost cap reaches the large table below. + assert!(matches!(read_sampled_function(&dict(5), vec![]), color::Func::Unsupported)); + // 32 outputs of an empty stream ask for 128 Mi zeros; also past the cap. Through parse() the + // refused tint transform takes the Separation fallback: tint 0.25 paints grey 0.75, not black. + let bytes = + page_pdf("/Tint cs 0.25 scn 0 0 200 100 re f", "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, dict(32), Some(vec![]))]); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(px(&doc, 50, 50), [0.75, 0.75, 0.75, 1.0]); +} + +#[test] +fn sampled_function_one_input_two_outputs() { + let func = + read_sampled_function("<< /FunctionType 0 /Domain [0 1] /Size [4] /Range [0 1 -1 1] /BitsPerSample 8 >>", vec![0, 255, 85, 170, 170, 85, 255, 0]); + assert!(matches!(func, color::Func::Sampled { outputs: 2, .. })); + // 0.5 → coordinate 1.5, halfway between samples 1 and 2; the second output decodes to [-1, 1]. + assert_eq!(func.eval(&[0.5]), vec![0.5, 0.0]); + // Inputs outside the domain take the end samples. + assert_eq!(func.eval(&[-1.0]), vec![0.0, 1.0]); + assert_eq!(func.eval(&[2.0]), vec![1.0, -1.0]); +} + +#[test] +fn sampled_function_two_inputs() { + let func = read_sampled_function( + "<< /FunctionType 0 /Domain [0 1 0 1] /Size [3 3] /Range [0 1 0 1] /BitsPerSample 8 >>", + vec![0, 0, 85, 0, 170, 0, 0, 85, 85, 85, 170, 85, 0, 170, 85, 170, 170, 170], + ); + assert!(matches!(func, color::Func::SampledN { outputs: 2, .. })); + // Entry (i, j), the first input varying fastest, holds (i / 3, j / 3); (0.25, 0.75) → (0.5, 1.5). + assert_eq!(func.eval(&[0.25, 0.75]), vec![1.0 / 6.0, 0.5]); + assert_eq!(func.eval(&[0.0, 0.0]), vec![0.0, 0.0]); + assert_eq!(func.eval(&[1.0, 1.0]), vec![2.0 / 3.0, 2.0 / 3.0]); +} + +#[test] +fn sampled_functions_have_at_most_32_inputs_and_outputs() { + // Each evaluation allocates and loops over every output, and shadings evaluate millions of + // times; no colour space has more than 32 components. + let outputs = |n: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [{}] /BitsPerSample 8 >>", "0 1 ".repeat(n)); + assert!(matches!(read_sampled_function(&outputs(32), vec![255; 64]), color::Func::Sampled { outputs: 32, .. })); + assert_eq!(read_sampled_function(&outputs(33), vec![255; 66]), color::Func::Unsupported); + let inputs = |m: usize| format!("<< /FunctionType 0 /Domain [{}] /Size [{}] /Range [0 1] /BitsPerSample 8 >>", "0 1 ".repeat(m), "1 ".repeat(m)); + let f = read_sampled_function(&inputs(32), vec![255]); + assert!(matches!(f, color::Func::SampledN { .. }), "{f:?}"); + assert_eq!(f.eval(&[0.5; 32]), vec![1.0]); + assert_eq!(read_sampled_function(&inputs(33), vec![255]), color::Func::Unsupported); +} + +/// Object 10: a sampled function with one input, one output and samples 0 and 1. +fn ramp() -> (u32, String, Option>) { + (10, "<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [0 1] /BitsPerSample 8 >>".into(), Some(vec![0, 255])) +} + +/// A stitching function of `n` equal pieces, each function object 10. +fn stitching_of_10(n: usize) -> String { + let bounds: Vec = (1..n).map(|i| (i as f64 / n as f64).to_string()).collect(); + format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>", "10 0 R ".repeat(n), bounds.join(" "), "0 1 ".repeat(n)) +} + +#[test] +fn function_arrays_hold_at_most_32_functions() { + // An array of one-output functions gives an output per function: 32 at most, as for a single + // function. (A stitching function's pieces are NOT capped this way; see the 100-piece test.) + let array = |n: usize| format!("[{}]", "10 0 R ".repeat(n)); + assert!(matches!(read_function(vec![(1, array(32), None), ramp()]), color::Func::Array(fs) if fs.len() == 32)); + assert_eq!(read_function(vec![(1, array(33), None), ramp()]), color::Func::Unsupported); +} + +#[test] +fn stitching_function_with_100_pieces_builds_and_evaluates() { + // A repeating gradient (Cairo, Inkscape) lists one sub-function once per repeat, often far + // more than 32 times; such a stitching function is valid and must build. Here object 10 (a + // 0 → 1 ramp) is listed 100 times, read once and shared. + let f = read_function(vec![(1, stitching_of_10(100), None), ramp()]); + let color::Func::Stitch { funcs, .. } = &f else { panic!("{f:?}") }; + assert_eq!(funcs.len(), 100); + let table = |g: &color::Func| match g { + color::Func::Sampled { samples, .. } => Some(samples.as_ptr()), + _ => None, + }; + assert!(table(&funcs[0]).is_some()); + assert!(funcs.iter().all(|g| g == &funcs[0] && table(g) == table(&funcs[0]))); + // The sawtooth's ends: piece 0 at t = 0 and piece 99 at t = 1 both map through the ramp. + assert_eq!(f.eval1(0.0), vec![0.0]); + assert_eq!(f.eval1(1.0), vec![1.0]); +} + +#[test] +fn a_function_repeated_in_an_array_is_read_once() { + // Read once per reference, 32 references to one 32 MiB table made 1 GiB. Now the object is + // read once: the copies are equal and share its table. + let table = |f: &color::Func| match f { + color::Func::Sampled { samples, .. } => samples.as_ptr(), + _ => std::ptr::null(), + }; + let array = read_function(vec![(1, format!("[{}]", "10 0 R ".repeat(32)), None), ramp()]); + let stitch = read_function(vec![(1, stitching_of_10(32), None), ramp()]); + for f in [&array, &stitch] { + let (color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. }) = f else { panic!("{f:?}") }; + assert_eq!(fs.len(), 32); + assert!(!table(&fs[0]).is_null()); + assert!(fs.iter().all(|g| g == &fs[0] && table(g) == table(&fs[0]))); + } + assert_eq!(array.eval1(0.5), vec![0.5; 32]); +} + +#[test] +fn saved_graphics_states_share_a_sampled_tint() { + // `q` saves a copy of the graphics state, colour spaces included: the copies share the tint + // transform's table. + let tint = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1 0 1 0 1] /BitsPerSample 8 >>"; + let data: Vec = (0..4096u32).flat_map(|i| [(i >> 4) as u8, 0, 255 - (i >> 4) as u8]).collect(); + let cs = read_color_space(vec![(1, "[/Separation /Ink /DeviceRGB 10 0 R]".into(), None), (10, tint.into(), Some(data.clone()))]); + let table = |cs: &color::Cs| match cs { + color::Cs::Tint { func: color::Func::Sampled { samples, .. }, .. } => samples.as_ptr(), + _ => std::ptr::null(), + }; + assert!(!table(&cs).is_null()); + assert_eq!(table(&cs.clone()), table(&cs)); + // A thousand nested `q` around a fill in that space: tint 1 is the last entry, red. + let content = format!("/Tint cs 1 scn {}0 0 200 100 re f {}", "q ".repeat(1000), "Q ".repeat(1000)); + let doc = parse(&page_pdf(&content, "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, tint.into(), Some(data))])).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(px(&doc, 100, 50), [1.0, 0.0, 0.0, 1.0]); +} + +/// A one-page PDF drawing image object 10 over the whole 200 × 100 page; `extra` holds more objects. +fn image_page(dict: &str, data: Vec, extra: Objs) -> Vec { + let mut objs: Objs = vec![(10, dict.into(), Some(data))]; + objs.extend(extra); + page_pdf("q 200 0 0 100 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs) +} + +/// Whether two colours match within rendering error. +fn near(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02) +} + +#[test] +fn indexed_space_reads_at_most_256_entries() { + // hival 10^12 asked the image palette for 10^12 colours (24 TB); an indexed palette holds at + // most 256 entries, so hival is 0 to 255. + let im = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1000000000000 <00>] /BitsPerComponent 8 >>"; + let doc = parse(&image_page(im, vec![200], vec![])).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + // Entry 200 is past the one-byte lookup string: black. + assert!(near(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 100, 50)); + let cs = read_color_space(vec![(1, "[/Indexed /DeviceRGB 1000000000000 <00>]".into(), None)]); + assert!(matches!(cs, color::Cs::Indexed { hival: 255, .. }), "{cs:?}"); +} + +#[test] +fn indexed_images_with_4_and_256_entries() { + let im = |cs: &str, w: usize| format!("<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {cs} /BitsPerComponent 8 >>"); + let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 3 ]", 4), vec![0, 1, 2, 3], vec![])).unwrap(); + for (x, c) in [(25, [1.0, 0.0, 0.0, 1.0]), (75, [0.0, 1.0, 0.0, 1.0]), (125, [0.0, 0.0, 1.0, 1.0]), (175, [1.0; 4])] { + assert!(near(px(&doc, x, 50), c), "x = {x}: {:?}", px(&doc, x, 50)); + } + // 256 entries, the lookup table in a stream: entry i is (i, 0, 255 − i). + let lookup: Vec = (0..=255u8).flat_map(|i| [i, 0, 255 - i]).collect(); + let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 255 11 0 R]", 2), vec![255, 0], vec![(11, "<< >>".into(), Some(lookup))])).unwrap(); + assert!(near(px(&doc, 50, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 50, 50)); + assert!(near(px(&doc, 150, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 150, 50)); +} + +/// Functions nested down the first element of arrays and stitching functions, outermost first. +fn nested_functions(f: &color::Func) -> Vec<&color::Func> { + let mut out = vec![f]; + let mut f = f; + while let color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. } = f + && let Some(first) = fs.first() + { + out.push(first); + f = first; + } + out +} + +/// Colour spaces nested down Indexed bases and Separation or DeviceN alternates, outermost first. +fn nested_spaces(cs: &color::Cs) -> Vec<&color::Cs> { + let mut out = vec![cs]; + let mut cs = cs; + while let color::Cs::Indexed { base: next, .. } | color::Cs::Tint { alt: next, .. } = cs { + cs = next; + out.push(cs); + } + out +} + +#[test] +fn function_array_containing_itself_stops_at_depth_8() { + // `[1 0 R]` as object 1: the reader recursed until the stack overflowed (an abort). + let f = read_function(vec![(1, "[1 0 R]".into(), None)]); + let chain = nested_functions(&f); + assert_eq!(chain.len(), 9); + assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Array(_)))); + assert_eq!(chain[8], &color::Func::Unsupported); + // As a tint transform: the innermost function's 0.5, in DeviceGray. + let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>"; + let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, "[10 0 R]".into(), None)])).unwrap(); + assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]); +} + +#[test] +fn stitching_function_containing_itself_stops_at_depth_8() { + let stitch = |me: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me} 0 R] /Bounds [] /Encode [0 1] >>"); + let f = read_function(vec![(1, stitch(1), None)]); + let chain = nested_functions(&f); + assert_eq!(chain.len(), 9); + assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. }))); + assert_eq!(chain[8], &color::Func::Unsupported); + let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>"; + let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, stitch(10), None)])).unwrap(); + assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]); +} + +#[test] +fn colour_spaces_based_on_themselves_stop_at_depth_8() { + // Object 1 as its own Indexed base, Separation alternate or ICCBased alternate: the reader + // recursed until the stack overflowed (an abort). + let indexed = read_color_space(vec![(1, "[/Indexed 1 0 R 1 <00>]".into(), None)]); + let separation = read_color_space(vec![(1, "[/Separation /Ink 1 0 R << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>]".into(), None)]); + for cs in [&indexed, &separation] { + let chain = nested_spaces(cs); + assert_eq!(chain.len(), 9); + assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. } | color::Cs::Tint { .. }))); + assert_eq!(chain[8], &color::Cs::Gray); + } + // An ICCBased space reads as its alternate: DeviceGray once the depth runs out. + let icc = read_color_space(vec![(1, "[/ICCBased 2 0 R]".into(), None), (2, "<< /N 3 /Alternate 1 0 R >>".into(), Some(vec![0; 16]))]); + assert_eq!(icc, color::Cs::Gray); + // A fill in the Indexed one paints black. + let doc = parse(&page_pdf("/CS0 cs 0 scn 0 0 200 100 re f", "/ColorSpace << /CS0 10 0 R >>", vec![(10, "[/Indexed 10 0 R 1 <00>]".into(), None)])).unwrap(); + assert_eq!(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn nesting_deeper_than_8_is_refused() { + // Chains of 100 distinct objects, each nesting the next (real files nest two or three + // levels): read down to depth 7, refused at depth 8. + let functions = |n: u32| -> Objs { + let stitch = |next: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>"); + (1..n).map(|i| (i, stitch(i + 1), None)).chain([(n, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]).collect() + }; + let f = read_function(functions(100)); + let chain = nested_functions(&f); + assert_eq!(chain.len(), 9); + assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. }))); + assert_eq!(chain[8], &color::Func::Unsupported); + // Three levels are read to the end. + let f = read_function(functions(3)); + assert_eq!(nested_functions(&f).len(), 3); + assert_eq!(f.eval1(0.25), vec![0.25]); + // Indexed bases. + let spaces: Objs = + (1..100).map(|i| (i, format!("[/Indexed {} 0 R 0 <00>]", i + 1), None)).chain([(100, "[/Indexed /DeviceRGB 0 ]".into(), None)]).collect(); + let cs = read_color_space(spaces); + let chain = nested_spaces(&cs); + assert_eq!(chain.len(), 9); + assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. }))); + assert_eq!(chain[8], &color::Cs::Gray); +} + +#[test] +fn a_fanning_out_reference_graph_is_refused_within_the_budget() { + // Eight objects, each an array of 32 references to the next, with a function at the bottom: + // depth 8 alone would still expand 32^7 copies. Each object is read once per array but its + // nodes are charged per copy, so the per-read node budget is spent after a few levels and the + // whole function is refused — fast and with bounded memory, not 32^7 nodes. + let objs: Objs = (1..=7u32) + .map(|i| (i, format!("[{}]", format!("{} 0 R ", i + 1).repeat(32)), None)) + .chain([(8, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]) + .collect(); + assert_eq!(read_function(objs), color::Func::Unsupported); +} + +#[test] +fn colour_space_chain_of_depth_3() { + // Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white). + let doc = parse(&image_page( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed 11 0 R 1 ] /BitsPerComponent 8 >>", + vec![0, 1], + vec![ + (11, "[/Separation /Ink 12 0 R 13 0 R]".into(), None), + (12, "[/ICCBased 14 0 R]".into(), None), + (13, "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>".into(), None), + (14, "<< /N 3 /Alternate /DeviceRGB >>".into(), Some(vec![0; 16])), + ], + )) + .unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert!(near(px(&doc, 50, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 50, 50)); + assert!(near(px(&doc, 150, 50), [1.0; 4]), "{:?}", px(&doc, 150, 50)); +} + +#[test] +fn stitching_function_with_a_reversed_domain() { + // `f64::clamp` panics on bounds in the wrong order, as `/Domain [1 0]` gives them. + let exp = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>"; + let f = read_function(vec![(1, format!("<< /FunctionType 3 /Domain [1 0] /Functions [{exp}] /Bounds [] /Encode [0 1] >>"), None)]); + assert_eq!(f.eval(&[0.25]), vec![0.0]); +} + +#[test] +fn sampled_function_with_a_reversed_domain() { + let f = read_sampled_function("<< /FunctionType 0 /Domain [1 0] /Size [2] /Range [0 1] /BitsPerSample 8 >>", vec![0, 255]); + assert_eq!(f.eval(&[0.25]), vec![0.0]); +} + +#[test] +fn calculator_function_with_a_reversed_range() { + let f = read_function(vec![(1, "<< /FunctionType 4 /Domain [0 1] /Range [1 0] >>".into(), Some(b"{ 0.5 add }".to_vec()))]); + assert_eq!(f.eval(&[0.25]), vec![0.75]); +} + #[test] fn multiple_pages_and_calculator_shadings() { let objs: Objs = vec![ diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index abcea9fa..d221ac59 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -85,7 +85,9 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { } fn sample_tick(i: i64, rate: u32) -> Tick { - Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64) + // Keep the requested sample when the rate does not divide the tick clock. + let t = Tick::from_units(i, i64::from(rate)); + if t.to_units_floor(i64::from(rate)) < i { Tick(t.0.saturating_add(1)) } else { t } } struct MixEnv<'a> { diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 6da17284..88fa5c7c 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -1,8 +1,9 @@ //! Layer render cache. //! //! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the -//! layer's own non-transform properties, its source item and the render options — not on its -//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed +//! layer's non-transform properties, its source item, selected-layer dependencies and the render +//! options. Its own transform is normally applied later; referenced layers' transforms can +//! affect their sampled pixels. [`LayerCache`] keeps those buffers keyed //! by a content hash of exactly those inputs, *evaluated at the frame time*, so: //! //! * a static layer (or one whose only animation is in Transform) renders once and is reused on @@ -25,6 +26,8 @@ use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; +mod dependencies; + /// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the /// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and /// buffers that were slow to render are written there (keys salted per project, see @@ -534,6 +537,9 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f #[allow(clippy::too_many_arguments)] fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); + ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h); + ctx.comp.enable_frame_blending.hash(&mut h); + dependencies::hash(&mut h, ctx, layer, effects)?; match &layer.source { LayerSource::Solid { item } => { let it = ctx.project.item(*item)?; diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs new file mode 100644 index 00000000..8ddee7a9 --- /dev/null +++ b/crates/render/src/cache/dependencies.rs @@ -0,0 +1,109 @@ +//! Content selected by layer parameters participates in the owner's cache key. Keep the +//! ordinary layer cache (including static self reads), and conservatively decline only +//! cyclic/over-budget graphs or expressions sampled at unknown times. +use super::{KeyHasher, hash_debug, hash_values}; +use crate::EvalCtx; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use 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) }; + deps.effects(ctx, layer, effects) +} + +struct Dependencies<'a> { + h: &'a mut KeyHasher, + path: Vec<(ItemId, LayerId)>, + remaining: usize, + other_times: bool, +} + +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); + if layer.switches.effects + && let Some(fx) = layer.effects() + { + for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) { + self.group(ctx, layer, g, 0)?; + } + } + self.other_times = previous; + Some(()) + } + + fn group(&mut self, ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> Option<()> { + if depth >= 32 { + return None; + } + for node in &g.children { + match node { + Node::Prop(p) => { + if let Some(id) = ctx.value(layer, p).as_layer() { + p.uid.hash(self.h); + id.hash(self.h); + let other = ctx.layer(LayerId(id)); + other.is_some().hash(self.h); + if let Some(other) = other { + if other.id == layer.id { + // Source/Masks never enter the same stack again. The owner + // key already covers its source and mask properties. + hash_debug(self.h, &ctx.world_matrix(other)); + } else { + self.layer(ctx, other)?; + } + } + } + } + Node::Group(sub) if sub.enabled => self.group(ctx, layer, sub, depth + 1)?, + _ => {} + } + } + Some(()) + } + + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + let id = (ctx.comp_id, layer.id); + if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { + return None; + } + self.remaining = self.remaining.checked_sub(1)?; + if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + return None; + } + self.path.push(id); + // Include transforms/parents as well as pixels: selected-layer anchors and + // continuously rasterized sources can depend on them. + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if super::time_dependent(layer) { + layer.layer_time(ctx.time).0.hash(self.h); + } + match layer.source { + LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let source = ctx.project.item(item); + hash_debug(self.h, &source); + if let Some(source) = source { + match &source.kind { + ItemKind::Footage(_) => ctx.source_time(layer).0.hash(self.h), + ItemKind::Comp(comp) => { + let time = ctx.source_time(layer); + time.0.hash(self.h); + let nested = EvalCtx::new(ctx.project, item, comp, time); + for child in &comp.layers { + self.layer(&nested, child)?; + } + } + _ => {} + } + } + } + _ => {} + } + self.effects(ctx, layer, None)?; + self.path.pop(); + Some(()) + } +} diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 91abef27..19785722 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -195,14 +195,22 @@ pub fn quantize(img: &mut Image, levels: f32) { #[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN #[inline(always)] fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) { - let a = (p[3].max(0.0).min(1.0) * levels + 0.5) as u32 as f32 * inv; + let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; let hi = [a, a, a, a]; for c in 0..3 { - p[c] = (p[c].max(0.0).min(hi[c]) * levels + 0.5) as u32 as f32 * inv; + p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; } p[3] = a; } +/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's +/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm +/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). +#[inline(always)] +fn round_half_up(v: f32) -> f32 { + if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } +} + /// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel. fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { if r.x0 >= r.x1 || r.y0 >= r.y1 { @@ -268,3 +276,22 @@ pub fn output_hdr(img: &mut Image, to_linear: Option, mode: HdrMode, *p = [o[0] * a, o[1] * a, o[2] * a, a]; }); } + +#[cfg(test)] +mod tests { + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: + /// 0..=levels, exact halves included. + #[test] + fn rounding_forms_agree() { + for levels in [255.0f32, 32_768.0] { + for i in 0..=(levels as u32 * 16) { + let v = i as f32 / 16.0; + assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}"); + } + for i in 0..=100_000 { + let v = (i as f32 / 100_000.0) * levels; + assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}"); + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 591213e6..46e10866 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -40,8 +40,16 @@ use rayon::prelude::*; pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option>; + /// [`FootageSource::frame`] for a render that shows it at `width` × `height`, at most half its + /// size: either that frame or, when the source can make it more cheaply than the whole frame + /// (movies, see `effectcraft-media`), the frame already resampled to that size as + /// [`effectcraft_raster::resample`] would. + fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option> { + self.frame(item, footage, t) + } /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at - /// `rate` Hz (`None` when unavailable). + /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; + /// a sample boundary uses the earliest representable tick that selects that sample. fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { None } @@ -61,6 +69,10 @@ pub trait FootageSource: Send + Sync { /// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the /// next reads decode again. Sources without a cache ignore it. fn purge(&self) {} + /// The file at `path` changed on disk (Reload Footage): drop what it keeps of it (decoded + /// frames, open decoders, parsed models), so the next reads see the new contents. Sources + /// without a cache ignore it. + fn forget(&self, _path: &str) {} /// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads /// (`None` = off); sources that don't decode audio ignore it. fn set_conform_folder(&self, _folder: Option) {} @@ -92,6 +104,7 @@ struct FxHost<'r, 'a, 'c> { origin: [f64; 2], /// Index of the effect being rendered (bounds `self_at` to effects before it). index: std::sync::atomic::AtomicUsize, + original: Option<&'c Buf>, } impl FxHost<'_, '_, '_> { @@ -138,12 +151,16 @@ pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option { + fn original(&self) -> Option<&Buf> { + self.original + } + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { self.r.active_accel().and_then(|a| a.particles()) } fn layer(&self, id: u64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; - if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { + if (other.id == self.layer.id && masks_and_effects) || self.r.depth > MAX_FX_DEPTH { return None; } let sub = self.r.nested(); @@ -154,7 +171,7 @@ impl EffectHost for FxHost<'_, '_, '_> { fn layer_masks(&self, id: u64) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; - if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { + if self.r.depth > MAX_FX_DEPTH { return None; } let sub = self.r.nested(); @@ -219,14 +236,17 @@ impl EffectHost for FxHost<'_, '_, '_> { fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; - if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { + if (other.id == self.layer.id && masks_and_effects) || !comp_time.is_finite() || self.r.depth > MAX_FX_DEPTH { return None; } let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time)); let sub = self.r.nested(); Some(if masks_and_effects { self.pixels(other, (*sub.content_buf(&ctx, other)?).clone(), true) + } else if other.id == self.layer.id { + self.pixels(other, sub.source(&ctx, other)?, true) } else { + // Preserve other-layer reads: time effects historically see source + masks. self.pixels(other, (*sub.layer_input(&ctx, other, 0)?).clone(), false) }) } @@ -953,7 +973,18 @@ impl<'a> Renderer<'a> { *q = [q[0] - origin[0], q[1] - origin[1]]; } } - let host = FxHost { r: self, ctx, layer, origin, index: Default::default() }; + // Capture once, before preceding effects can expand or replace the input. An + // adjustment stack's original is the comp below, not its solid source. + let needs_original = + fx.groups().take(limit).any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite")); + let mut original = None; + if needs_original { + target.cpu(&mut |buf| { + original = Some(buf.clone()); + buf + }); + } + let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() }; let env = EffectEnv { masks: &mask_shapes, host: Some(&host), @@ -1104,8 +1135,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let pf = self.proxy_for(*item).unwrap_or(f); - let img = self.footage_frame(ctx, layer, *item, pf, lt)?; + let proxy = self.proxy_for(*item); + let pf = proxy.unwrap_or(f); + let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); + let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), @@ -1127,12 +1160,11 @@ impl<'a> Renderer<'a> { let s = self.opts.scale; let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. - let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 { - let rw = ((w as f64 * s).round() as u32).max(1); - let rh = ((h as f64 * s).round() as u32).max(1); - Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s } - } else { - Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k } + let mut buf = match self.reduced_size(w, h) { + // The source resampled it already (`FootageSource::frame_at_size`). + Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s }, + Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }, + _ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }, }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -1140,6 +1172,13 @@ impl<'a> Renderer<'a> { buf } + /// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage + /// is resampled to it (below 3/4 scale). + fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> { + let s = self.opts.scale; + (s < 0.75).then(|| (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1))) + } + /// Authored colours into the working space (linear working spaces). fn authored(&self, mut buf: Buf) -> Buf { if let Some(c) = self.pipe.authored_in() { @@ -1151,7 +1190,8 @@ impl<'a> Renderer<'a> { /// A footage frame at source time `t`, frame-blended between the two nearest source frames /// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and /// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping). - fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + /// `size`: a plain frame may come resampled to it ([`FootageSource::frame_at_size`]). + fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { // Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate. if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); @@ -1162,11 +1202,13 @@ impl<'a> Renderer<'a> { let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f))); } - let img = self.footage_frame_raw(ctx, layer, item, f, t)?; + // Interpretation is per pixel but not linear: it runs on the frame before resampling. + let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } } - fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let (i, w) = frame_position(t, f.frame_rate); @@ -1194,7 +1236,10 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame(item, f, t) + match size { + Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h), + None => self.footage.frame(item, f, t), + } } /// Source → masks, clamped/quantised to the project depth. @@ -2152,6 +2197,8 @@ mod tests_collapse; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_footage_size; +#[cfg(test)] mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 93c9a1a5..c22df8ab 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -586,3 +586,186 @@ fn layer_parameters_see_a_whole_shape_layer() { assert!(f.get(x, y)[3] < 0.01, "outside at ({x}, {y}): {:?}", f.get(x, y)); } } + +#[test] +fn set_matte_self_source_and_masks_precede_effects() { + for source in [0, 1] { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut next = p.next_id; + let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(mask.into()); + let id = l.id.0; + add_effect_200(&mut p, &mut l, "ec.channel.invert", &[("channel", Value::Enum(effectcraft_effects::INVERT_ALPHA))]); + add_effect_200( + &mut p, + &mut l, + "ec.channel.setmatte", + &[ + ("takeMatteFromLayer", Value::Layer(Some(id))), + ("takeMatteFromLayerSource", Value::Enum(source)), + ("compositeMatteWithOriginal", Value::Bool(false)), + ], + ); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.get(50, 50)[3] > 0.99, "original alpha must survive preceding alpha inversion"); + assert!((img.get(150, 50)[3] - if source == 0 { 1.0 } else { 0.0 }).abs() < 0.01, "Source and Masks must remain distinct"); + } +} + +#[test] +fn cc_composite_restores_stack_input_on_layers_and_adjustments() { + for adjustment in [false, true] { + for mode in [0, 1] { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + l.switches.adjustment = adjustment; + add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]); + add_effect_200(&mut p, &mut l, "ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(mode))]); + p.comp_mut(cid).unwrap().layers.push(l); + if adjustment { + let bg = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + p.comp_mut(cid).unwrap().layers.push(bg); + } + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let want = if mode == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] }; + assert_eq!(img.get(50, 50), want, "adjustment={adjustment} mode={mode}"); + } + } +} + +#[test] +fn self_inputs_share_source_semantics_across_effects_and_times() { + use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer}; + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut next = p.next_id; + let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(mask.into()); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let c = p.comp(cid).unwrap(); + 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 }; + assert_eq!(host.layer(l.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0); + assert_eq!(host.layer_masks(l.id.0).unwrap().buf.img.get(150, 50)[3], 0.0); + assert_eq!(host.layer_at(l.id.0, 0.5, false).unwrap().buf.img.get(150, 50)[3], 1.0); + assert!(host.layer(l.id.0, true).is_none(), "full-stack self references must not recurse"); + assert!(host.layer_at(l.id.0, 0.5, true).is_none()); + assert!(host.layer_at(l.id.0, f64::NAN, false).is_none()); + // A second built-in using the shared selector: Invert(red) then Blend(original red). + l.props.sub_mut("masks").unwrap().children.clear(); + add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]); + let id = l.id.0; + add_effect_200( + &mut p, + &mut l, + "ec.channel.blend", + &[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))], + ); + p.comp_mut(cid).unwrap().layers = vec![l]; + let c = p.comp(cid).unwrap(); + let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); + assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some()); + assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 2).is_some()); + assert_eq!(render_frame(&p, cid, Tick::ZERO, 1.0).get(50, 50), [1.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn timed_source_reads_preserve_other_layer_masks() { + use effectcraft_effects::EffectHost; + let (mut p, cid, comp) = setup(); + let owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut other = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100); + let mut next = p.next_id; + let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + other.props.sub_mut("masks").unwrap().children.push(mask.into()); + p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()]; + let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); + let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None }; + // Current Source and timed Source had different contracts before this fix. Keep + // the existing timed read (used by Clone/Time effects) masked for other layers. + assert_eq!(host.layer(other.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0); + let timed = host.layer_at(other.id.0, 0.5, false).unwrap(); + assert_eq!(timed.buf.img.get(50, 50)[3], 1.0); + assert_eq!(timed.buf.img.get(150, 50)[3], 0.0); +} + +#[test] +fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() { + let (mut p, cid, comp) = setup(); + let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut source = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100); + let third = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); + add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); + add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); + p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated]; + let key = |p: &Project, time: f64, limit| { + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time)); + crate::cache::input_key(&ctx, &comp.layers[0], 1.0, false, false, limit).unwrap() + }; + let initial = key(&p, 0.0, 1); + let input = key(&p, 0.0, 0); + assert_eq!(initial, key(&p, 0.5, 1), "static dependencies reuse across frames"); + let cache = crate::LayerCache::default(); + let before = render_cached(&p, cid, Tick::ZERO, Some(&cache)); + render_cached(&p, cid, Tick::ZERO, Some(&cache)); + assert!(cache.stats().hits > 0, "layer parameters must not disable caching"); + let LayerSource::Solid { item: unrelated } = p.comp(cid).unwrap().layers[3].source else { panic!() }; + let ItemKind::Solid(s) = &mut p.item_mut(unrelated).unwrap().kind else { panic!() }; + s.color = [0.9, 0.8, 0.7]; + assert_eq!(initial, key(&p, 0.0, 1), "unrelated edits preserve the owner key"); + let LayerSource::Solid { item: third } = p.comp(cid).unwrap().layers[2].source else { panic!() }; + let ItemKind::Solid(s) = &mut p.item_mut(third).unwrap().kind else { panic!() }; + s.color = [1.0, 0.0, 1.0]; + assert_ne!(initial, key(&p, 0.0, 1), "transitive source edits invalidate the owner"); + assert_eq!(input, key(&p, 0.0, 0), "effects after an input prefix do not affect its key"); + let cached = render_cached(&p, cid, Tick::ZERO, Some(&cache)); + let fresh = render_cached(&p, cid, Tick::ZERO, None); + assert_same(&cached, &fresh, "transitive dependency edit"); + assert_ne!(before.get(50, 50), fresh.get(50, 50)); + let owner_id = p.comp(cid).unwrap().layers[0].id; + p.comp_mut(cid).unwrap().layers[1].props.prop_mut("effects/#1/blendWithLayer").unwrap().value = Value::Layer(Some(owner_id.0)); + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO); + assert!(crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false).is_none(), "cycles decline caching without recursion"); +} + +#[test] +fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { + let (mut p, cid, comp) = setup(); + let mut child = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + let LayerSource::Solid { item: source } = child.source else { panic!() }; + child.props.prop_mut("transform/opacity").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))]; + let mut nested = comp.clone(); + nested.layers = vec![child]; + let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into())); + let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None); + let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); + p.comp_mut(cid).unwrap().layers = vec![owner, pre]; + let key = |p: &Project, time| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap() + }; + let cache = crate::LayerCache::default(); + let before = render_cached(&p, cid, Tick::ZERO, Some(&cache)); + let initial = key(&p, Tick::ZERO); + let later = Tick::from_seconds_f64(0.5); + assert_ne!(initial, key(&p, later)); + assert_same(&render_cached(&p, cid, later, Some(&cache)), &render_cached(&p, cid, later, None), "animated precomp dependency"); + let ItemKind::Solid(s) = &mut p.item_mut(source).unwrap().kind else { panic!() }; + s.color = [0.0, 1.0, 0.0]; + assert_ne!(initial, key(&p, Tick::ZERO), "items used inside the precomp invalidate the owner"); + let fresh = render_cached(&p, cid, Tick::ZERO, None); + assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit"); + assert_ne!(before.get(50, 50), fresh.get(50, 50)); +} diff --git a/crates/render/src/tests_footage_size.rs b/crates/render/src/tests_footage_size.rs new file mode 100644 index 00000000..88df726e --- /dev/null +++ b/crates/render/src/tests_footage_size.rs @@ -0,0 +1,83 @@ +//! Footage at a reduced resolution: a source that resamples frames itself +//! ([`FootageSource::frame_at_size`], movies in `effectcraft-media`) renders what resampling the +//! full frame renders. + +use std::sync::{Arc, Mutex}; + +use rayon::prelude::*; + +use effectcraft_color::Label; +use effectcraft_project::build; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{FootageSource, Image, RenderOpts, Renderer}; + +/// Footage with a textured frame; `sizes` (when set) resamples it itself and records the sizes +/// it was asked for. +struct Clip { + sizes: Option>>, +} + +impl FootageSource for Clip { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + let mut img = Image::new(f.width, f.height); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let s = (0.5 + 0.3 * (x as f32 * 0.37 + y as f32 * 0.11).sin()) * 0.9; + *p = [s, 1.0 - s, s * 0.5, 1.0]; + } + }); + Some(Arc::new(img)) + } + fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, w: u32, h: u32) -> Option> { + let Some(sizes) = &self.sizes else { return self.frame(item, f, t) }; + sizes.lock().unwrap().push((w, h)); + Some(Arc::new(effectcraft_raster::resample(&*self.frame(item, f, t)?, w, h))) + } +} + +fn project(depth: BitDepth, invert_alpha: bool) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let comp = Comp::new(96, 54, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "clip.mp4".into(), + kind: FootageKind::Video, + width: 96, + height: 54, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + alpha: AlphaMode::Ignore, + loop_count: 1, + invert_alpha, + ..Default::default() + }; + let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (96, 54), None); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +#[test] +fn reduced_renders_take_footage_resampled_by_the_source() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let (p, cid) = project(depth, false); + for (scale, asked) in [(1.0, None), (0.75, None), (0.5, Some((48, 27))), (1.0 / 3.0, Some((32, 18))), (0.25, Some((24, 14)))] { + let opts = RenderOpts { scale, ..Default::default() }; + let plain = Renderer::new(&p, &Clip { sizes: None }, opts).comp_frame(cid, Tick::ZERO); + let sized = Clip { sizes: Some(Mutex::default()) }; + let frame = Renderer::new(&p, &sized, opts).comp_frame(cid, Tick::ZERO); + assert_eq!(frame, plain, "{depth:?} at {scale}"); + assert_eq!(sized.sizes.unwrap().into_inner().unwrap(), asked.into_iter().collect::>(), "{depth:?} at {scale}"); + } + } + // Interpreted pixels (Invert Alpha) are interpreted at full size, then resampled. + let (p, cid) = project(BitDepth::Bpc8, true); + let sized = Clip { sizes: Some(Mutex::default()) }; + Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty()); +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f52765e7..2b8085d3 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -480,9 +480,16 @@ TextDocument.prototype = { get smallCaps() { return this.__style("smallCaps", false); }, set smallCaps(v) { this.__set("smallCaps", !!v); }, get superscript() { return this.__style("baseline", "normal") === "superscript"; }, - set superscript(v) { this.__set("superscript", !!v); }, + set superscript(v) { + // The pending document reads the same canonical baseline that setValue will apply. + if (v) this.__set("baseline", "superscript"); + else if (this.superscript) this.__set("baseline", "normal"); + }, get subscript() { return this.__style("baseline", "normal") === "subscript"; }, - set subscript(v) { this.__set("subscript", !!v); }, + set subscript(v) { + if (v) this.__set("baseline", "subscript"); + else if (this.subscript) this.__set("baseline", "normal"); + }, get tsume() { return this.__style("tsume", 0); }, set tsume(v) { this.__set("tsume", __num(v)); }, get boxText() { return !!(this.__base && this.__base.box_size); }, @@ -574,7 +581,9 @@ PropertyBase.prototype = { return false; }, set selected(v) { - if (v) __call("prop.select", { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid, add: true }); + var ref = { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid }; + if (v) __call("prop.select", { comp: ref.comp, layer: ref.layer, prop: ref.prop, add: true }); + else __call("prop.deselect", ref); }, propertyGroup: function (n) { var p = this; @@ -1228,6 +1237,7 @@ LayerCollection.prototype = { if (name !== undefined) p.name = String(name); if (width !== undefined) p.width = Math.round(__num(width, "width")); if (height !== undefined) p.height = Math.round(__num(height, "height")); + if (pixelAspect !== undefined) p.pixelAspect = __num(pixelAspect, "pixelAspect"); return this.__dur(this.__new(__call("layer.newSolid", p)), duration); }, addNull: function (duration) { return this.__dur(this.__new(__call("layer.newNull", { comp: this.__comp })), duration); }, @@ -1335,7 +1345,18 @@ Item.prototype = { get parentFolder() { return __item(this.__info().parent); }, set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); }, get selected() { return this.__info().selected; }, - set selected(v) { if (v) __call("project.select", { items: [this.__id], add: true }); }, + set selected(v) { + var selection = __get("project").selection; + var selected = selection.indexOf(this.__id) !== -1; + if (!!v === selected) return; + if (v) { + __call("project.select", { items: [this.__id], add: true }); + } else { + var id = this.__id; + // Replace just this item, preserving the other Project panel selections and their order. + __call("project.select", { items: selection.filter(function (item) { return item !== id; }) }); + } + }, get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); }, remove: function () { __call("project.delete", { items: [this.__id] }); }, }; @@ -1703,7 +1724,10 @@ Project.prototype = { importFile: function (opts) { var f = opts instanceof ImportOptions ? opts.file : opts; if (!f) throw __err("importFile(): no file"); - var r = __call("file.import", { paths: [__str(f)] }); + var args = { paths: [__str(f)] }; + // After Effects imports just the picked file unless ImportOptions.sequence is set. + if (opts instanceof ImportOptions) args.sequence = !!opts.sequence; + var r = __call("file.import", args); if (r.errors && r.errors.length) throw __err(r.errors.join("; ")); return __item(r.items[0]); }, diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 10deb79b..946db49d 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -524,3 +524,168 @@ fn comp_preserves_nested_frame_rate_and_resolution() { let (_, c) = comp_named(&s, "Nested"); assert!(c.preserve_frame_rate && c.preserve_resolution); } + +#[test] +fn text_document_baseline_flags_reflect_pending_edits_and_applied_value() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Baseline", 64, 64, 1, 1, 24); + var src = comp.layers.addText("AB").text.sourceText; + var doc = src.value; + doc.superscript = true; + var pendingSuper = [doc.superscript, doc.subscript]; + doc.text = doc.superscript ? "super" : "normal"; + src.setValue(doc); + var appliedSuper = [src.value.text, src.value.superscript, src.value.subscript]; + doc = src.value; + doc.subscript = true; + var pendingSub = [doc.superscript, doc.subscript]; + src.setValue(doc); + var appliedSub = [src.value.superscript, src.value.subscript]; + doc = src.value; + doc.subscript = false; + var cleared = [doc.superscript, doc.subscript]; + src.setValue(doc); + var appliedClear = [src.value.superscript, src.value.subscript]; + doc = src.value; + doc.superscript = true; + doc.superscript = false; + var repeatClear = [doc.superscript, doc.subscript]; + [pendingSuper, appliedSuper, pendingSub, appliedSub, cleared, appliedClear, repeatClear]; + "#, + ); + assert_eq!(o.result, json!([[true, false], ["super", true, false], [false, true], [false, true], [false, false], [false, false], [false, false]])); +} + +#[test] +fn setting_item_selected_false_preserves_other_project_items() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var target = app.project.items.addFolder("Target"); + var keep = app.project.items.addFolder("Keep"); + app.run("project.select", {items: []}); + keep.selected = true; + target.selected = true; + var states = [[target.selected, keep.selected]]; + target.selected = false; + states.push([target.selected, keep.selected]); + target.selected = false; // idempotent: never remove Keep + states.push([target.selected, keep.selected]); + target.selected = true; + states.push([target.selected, keep.selected]); + app.project.selection.map(function (item) { return item.name; }).join(",") + + "|" + JSON.stringify(states); + "#, + ); + assert_eq!(o.result, json!("Keep,Target|[[true,true],[false,true],[false,true],[true,true]]")); +} + +#[test] +fn add_solid_keeps_the_requested_pixel_aspect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("PA", 64, 32, 1, 1, 10); + var square = c.layers.addSolid([1, 0, 0], "Square", 16, 16, 1); + var wide = c.layers.addSolid([0, 1, 0], "Wide", 16, 16, 2); + var inherited = c.layers.addSolid([0, 0, 1], "Inherited", 16, 16); + [c.pixelAspect, square.source.pixelAspect, wide.source.pixelAspect, inherited.source.pixelAspect, + wide.source.width, wide.source.height] + "#, + ); + assert_eq!(o.result, json!([1, 1, 2, 1, 16, 16])); + let bad = run_code(&mut s, r#"app.project.items.addComp("Q", 8, 8, 1, 1, 10).layers.addSolid([1, 1, 1], "Bad", 8, 8, 0)"#, "bad.jsx"); + assert!(bad.error.is_some_and(|e| e.message.contains("pixelAspect"))); +} + +#[test] +fn property_selected_false_preserves_other_property_and_key_selections() { + let mut s = session(); + let out = ok( + &mut s, + r#" + var c = app.project.items.addComp("Selection", 64, 64, 1, 1, 10); + c.openInViewer(); + var layer = c.layers.addSolid([1, 0, 0], "Layer", 8, 8, 1); + var opacity = layer.transform.opacity; + var rotation = layer.transform.rotation; + rotation.setValueAtTime(0, 0); + rotation.setValueAtTime(0.5, 45); + app.run("edit.deselectAll", {}); + rotation.selected = true; + opacity.selected = true; + var states = [[opacity.selected, rotation.selected]]; + opacity.selected = false; + states.push([opacity.selected, rotation.selected]); + opacity.selected = false; + states.push([opacity.selected, rotation.selected]); + opacity.selected = true; + states.push([opacity.selected, rotation.selected]); + [states, rotation.selectedKeys.length, c.selectedProperties.length]; + "#, + ); + assert_eq!(out.result, json!([[[true, true], [false, true], [false, true], [true, true]], 2, 2])); +} + +#[test] +fn group_selected_false_deselects_masks_and_effects() { + let mut s = session(); + let out = ok( + &mut s, + r#" + var c = app.project.items.addComp("Groups", 64, 64, 1, 1, 10); + c.openInViewer(); + var l = c.layers.addSolid([1, 0, 0], "Layer", 64, 64, 1); + var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom"); + var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur"); + app.run("edit.deselectAll", {}); + m.selected = true; + fx.selected = true; + var states = [[m.selected, fx.selected]]; + m.selected = false; + states.push([m.selected, fx.selected]); + fx.selected = false; + states.push([m.selected, fx.selected]); + states; + "#, + ); + assert_eq!(out.result, json!([[true, true], [false, true], [false, false]])); +} + +/// Probes every file as a 64x32 still (the media layer's single-file probe). +struct Stills; +impl effectcraft_engine::Importer for Stills { + fn probe(&self, path: &str) -> Result { + use effectcraft_engine::project::{Footage, FootageKind}; + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) + } +} + +/// `importFile(new ImportOptions(file))` imports just the picked file unless `sequence` is set (#475). +#[test] +fn import_options_sequence_picks_one_still_or_the_run() { + use effectcraft_engine::project::{FootageKind, ItemKind}; + let dir = std::env::temp_dir().join(format!("ec-script-seq-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for n in ["shot_0001.png", "shot_0002.png", "shot_0003.png"] { + std::fs::write(dir.join(n), b"").unwrap(); + } + let pick = dir.join("shot_0002.png").to_string_lossy().replace('\\', "/"); + let mut s = session(); + s.importer = Some(Arc::new(Stills)); + let kind_of = |s: &Session, name: &str| { + let it = s.project.items.values().find(|i| i.name == name).unwrap_or_else(|| panic!("no item {name}")); + let ItemKind::Footage(f) = &it.kind else { panic!("footage") }; + f.kind + }; + ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); app.project.importFile(io);")); + assert_eq!(kind_of(&s, "shot_0002.png"), FootageKind::Still); + ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); io.sequence = true; app.project.importFile(io);")); + assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 2b17cf4c..f5697156 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -186,8 +186,9 @@ impl FrameRate { ]; pub fn new(num: i64, den: i64) -> Self { - let g = gcd(num.abs(), den.abs()).max(1); - FrameRate { num: num / g, den: den / g } + // Magnitudes can be 2^63 for i64::MIN; keep them unsigned and divide in i128. + let g = gcd(num.unsigned_abs(), den.unsigned_abs()).max(1) as i128; + FrameRate { num: (num as i128 / g) as i64, den: (den as i128 / g) as i64 } } /// Closest standard rate for a float (e.g. from a container's average rate). @@ -214,10 +215,16 @@ impl FrameRate { clamp128((t.0 as i128 * self.num as i128).div_euclid(TICKS_PER_SECOND as i128 * nonzero(self.den))) } - /// Start tick of frame `f`. + /// First tick belonging to frame `f` (ceiling at a fractional tick boundary). + /// Common broadcast rates have exact integer boundaries. For other rates, flooring would + /// put the returned tick in frame `f - 1`, since [`Self::frame_at`] floors its frame index. pub fn tick_of(self, f: i64) -> Tick { - let n = f as i128 * TICKS_PER_SECOND as i128 * self.den as i128; - Tick(clamp128(n.div_euclid(nonzero(self.num)))) + let n = (f as i128).saturating_mul(TICKS_PER_SECOND as i128).saturating_mul(self.den as i128); + let d = nonzero(self.num); + // The only signed division overflow (MIN / -1) has a positive result beyond i128. + let q = n.checked_div_euclid(d).unwrap_or(i128::MAX); + let r = n.checked_rem_euclid(d).unwrap_or(0); + Tick(clamp128(if d > 0 && r != 0 { q.saturating_add(1) } else { q })) } /// Snap `t` down to a frame boundary. @@ -234,7 +241,10 @@ impl FrameRate { /// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976. pub fn timecode_base(self) -> i64 { - (self.num.saturating_add(self.den - 1) / nonzero(self.den) as i64).max(1) + if self.num <= 0 || self.den <= 0 { + return 1; + } + clamp128((i128::from(self.num) + i128::from(self.den) - 1) / i128::from(self.den)).max(1) } /// NTSC (x/1001) rates can use drop-frame timecode. @@ -265,7 +275,7 @@ impl fmt::Display for FrameRate { } } -fn gcd(mut a: i64, mut b: i64) -> i64 { +fn gcd(mut a: u64, mut b: u64) -> u64 { while b != 0 { (a, b) = (b, a % b); } @@ -457,7 +467,8 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i .ok_or_else(too_large)? }; Ok(match rel { - Some(sign) => current.saturating_add(sign * frames), + Some(-1) => current.saturating_sub(frames), + Some(_) => current.saturating_add(frames), None => frames, }) } @@ -520,6 +531,19 @@ pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_ mod tests { use super::*; + #[test] + fn frame_rate_constructors_accept_extreme_numbers() { + assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); + assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 }); + assert_eq!(FrameRate::new(0, i64::MIN), FrameRate { num: 0, den: -1 }); + assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 }); + assert_eq!(FrameRate::new(0, 0), FrameRate { num: 0, den: 0 }); + assert_eq!(FrameRate::from_f64(f64::NAN), FrameRate { num: 0, den: 1 }); + for fps in [f64::NEG_INFINITY, -1e308, -9.2e18, f64::INFINITY, 1e308] { + assert_eq!(FrameRate::from_f64(fps), FrameRate::new((fps * 1000.0).round() as i64, 1000)); + } + } + #[test] fn hostile_numbers_never_overflow() { // `layer.timing {"delta": -1e308}` over the control channel overflowed `snap_nearest`; @@ -549,6 +573,42 @@ mod tests { let huge = FrameRate { num: i64::MAX, den: 1 }; let _ = format_timecode_frames(i64::MAX, huge, false); } + #[test] + fn hostile_rates_format_and_parse_without_panicking() { + for rate in [ + FrameRate::new(i64::MIN, -1), + FrameRate { num: 1, den: i64::MIN }, + FrameRate::new(i64::MIN, i64::MIN), + FrameRate { num: 0, den: 0 }, + FrameRate { num: -30, den: 1 }, + ] { + assert_eq!(rate.timecode_base(), 1); + assert_eq!(format_timecode_frames(0, rate, false), "00:00:00:00"); + assert_eq!(parse_timecode("0:0:0:0", rate, false, 0).unwrap(), 0); + } + for rate in [FrameRate { num: i64::MAX, den: 1 }, FrameRate { num: i64::MAX, den: 1001 }] { + let expected = ((i128::from(rate.num) + i128::from(rate.den) - 1) / i128::from(rate.den)) as i64; + assert_eq!(rate.timecode_base(), expected); + } + } + + #[test] + fn negative_relative_min_field_saturates_in_the_requested_direction() { + assert_eq!(parse_timecode("-0:0:0:-9223372036854775808", FrameRate::FPS_30, false, 0).unwrap(), i64::MAX); + assert_eq!(parse_timecode("+0:0:0:-9223372036854775808", FrameRate::FPS_30, false, 0).unwrap(), i64::MIN); + assert_eq!(parse_timecode("-1", FrameRate::FPS_30, false, i64::MIN).unwrap(), i64::MIN); + } + + #[test] + fn common_rate_timecode_roundtrips_keep_their_existing_behavior() { + for rate in FrameRate::COMMON { + for frame in [-10_000, -1, 0, 1, 10_000] { + let text = format_timecode_frames(frame, rate, false); + assert_eq!(parse_timecode(&text, rate, false, 0).unwrap(), frame, "{rate}: {text}"); + } + } + } + use proptest::prelude::*; #[test] @@ -562,6 +622,50 @@ mod tests { } } + #[test] + fn fractional_tick_frame_boundaries_belong_to_the_requested_frame() { + let rate = FrameRate::from_f64(12.345); + assert_eq!(rate, FrameRate::new(2469, 200)); + assert_eq!(rate.tick_of(1), Tick(20_576_427_704)); + assert_eq!(rate.tick_of(-1), Tick(-20_576_427_703)); + for frame in [-10_000, -2, -1, 0, 1, 2, 10_000] { + let start = rate.tick_of(frame); + assert_eq!(rate.frame_at(start), frame); + assert_eq!(rate.frame_at(start - Tick(1)), frame - 1); + assert_eq!(rate.snap(start), start); + assert_eq!(rate.snap_nearest(start), start); + let next = rate.tick_of(frame + 1); + assert_eq!(rate.frame_at(next - Tick(1)), frame); + } + } + + #[test] + fn common_rate_boundaries_keep_their_exact_ticks() { + for rate in FrameRate::COMMON { + for frame in [-10_000, -1, 0, 1, 10_000] { + let expected = frame as i128 * TICKS_PER_SECOND as i128 * rate.den as i128 / rate.num as i128; + assert_eq!(rate.tick_of(frame), Tick(expected as i64), "{rate}, frame {frame}"); + assert_eq!(rate.frame_at(rate.tick_of(frame) - Tick(1)), frame - 1); + } + } + } + + #[test] + fn extreme_frame_boundaries_saturate_without_overflowing() { + for rate in [ + FrameRate { num: 1, den: i64::MAX }, + FrameRate { num: -1, den: i64::MAX }, + FrameRate { num: i64::MAX, den: i64::MAX }, + FrameRate { num: 0, den: i64::MAX }, + ] { + for frame in [i64::MIN, i64::MAX] { + let _ = rate.tick_of(frame); + } + } + assert_eq!(FrameRate { num: 1, den: i64::MAX }.tick_of(i64::MAX), Tick(i64::MAX)); + assert_eq!(FrameRate { num: 1, den: i64::MAX }.tick_of(i64::MIN), Tick(i64::MIN)); + } + #[test] fn drop_frame_known_values() { let r = FrameRate::FPS_29_97; @@ -608,6 +712,13 @@ mod tests { } proptest! { + #[test] + fn frame_rate_normalization_preserves_exact_ratio(num in any::(), den in any::()) { + let rate = FrameRate::new(num, den); + prop_assert_eq!(num as i128 * rate.den as i128, den as i128 * rate.num as i128); + prop_assert_eq!(FrameRate::new(rate.num, rate.den), rate); + } + #[test] fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) { let r = FrameRate::COMMON[ri]; @@ -615,6 +726,24 @@ mod tests { prop_assert_eq!(r.frame_at(r.tick_of(f) + r.frame_duration() - Tick(1)), f); } + #[test] + fn arbitrary_frame_boundaries_roundtrip_and_interior_ticks_stay_in_their_frame( + frame in -1_000_000i64..1_000_000, + milli_fps in 1_000i64..1_000_000, + phase in 1i64..1_000, + ) { + let rate = FrameRate::new(milli_fps, 1000); + let start = rate.tick_of(frame); + let next = rate.tick_of(frame + 1); + prop_assert_eq!(rate.frame_at(start), frame); + prop_assert_eq!(rate.frame_at(start - Tick(1)), frame - 1); + prop_assert_eq!(rate.frame_at(next - Tick(1)), frame); + let interior = start + (next - start).mul_ratio(phase, 1000); + prop_assert!(interior > start && interior < next); + prop_assert_eq!(rate.frame_at(interior), frame); + prop_assert_eq!(rate.snap(interior), start); + } + #[test] fn drop_frame_bijection(f in 0i64..(24 * 107_892)) { let r = FrameRate::FPS_29_97; diff --git a/crates/time/tests/relative_min_cancellation.rs b/crates/time/tests/relative_min_cancellation.rs new file mode 100644 index 00000000..9e63f1ba --- /dev/null +++ b/crates/time/tests/relative_min_cancellation.rs @@ -0,0 +1,29 @@ +use effectcraft_time::{FrameRate, parse_timecode}; + +const RELATIVE_NEGATIVE_MIN_FIELD: &str = "-0:0:0:-9223372036854775808"; + +#[test] +fn normal_relative_and_current_zero_controls_keep_existing_results() { + let rate = FrameRate::FPS_30; + assert_eq!(parse_timecode("+15", rate, false, 100).unwrap(), 115); + assert_eq!(parse_timecode("-1.00", rate, false, 100).unwrap(), 70); + assert_eq!(parse_timecode("-1", rate, false, i64::MIN).unwrap(), i64::MIN); + assert_eq!(parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, rate, false, 0).unwrap(), i64::MAX); + assert_eq!(parse_timecode("+0:0:0:-9223372036854775808", rate, false, 0).unwrap(), i64::MIN); +} + +#[test] +fn relative_negative_min_field_preserves_cancellation_from_negative_one() { + let actual = parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, FrameRate::FPS_30, false, -1).unwrap(); + eprintln!("relative MIN field: current=-1 actual={actual} expected={}", i64::MAX); + // The relative offset is positive 2^63; minus one is exactly i64::MAX. + assert_eq!(actual, i64::MAX, "saturation must preserve cancellation with the current frame"); +} + +#[test] +fn relative_negative_min_field_cancels_the_minimum_current_frame() { + let actual = parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, FrameRate::FPS_30, false, i64::MIN).unwrap(); + eprintln!("relative MIN field: current=MIN actual={actual} expected=0"); + // The starting frame is negative 2^63; the relative offset is positive 2^63. + assert_eq!(actual, 0, "opposite extreme relative offsets must cancel before saturation"); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 058f601b..65bca880 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -467,8 +467,9 @@ impl DockNode { *self = (**a).clone(); } } else if let DockNode::Tabs { panels, active } = self { + let shown = panels.get(*active).copied(); panels.retain(|x| *x != p); - *active = (*active).min(panels.len().saturating_sub(1)); + *active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1))); } else if let DockNode::Stack { entries } = self { entries.retain(|e| e.panel != p); } @@ -692,8 +693,9 @@ impl Layout { let mut found = false; for f in &mut self.floating { if let Some(i) = f.panels.iter().position(|x| *x == p) { + let shown = f.panels.get(f.active).copied(); f.panels.remove(i); - f.active = f.active.min(f.panels.len().saturating_sub(1)); + f.active = shown.and_then(|shown| f.panels.iter().position(|x| *x == shown)).unwrap_or(f.active.min(f.panels.len().saturating_sub(1))); found = true; } } @@ -1416,4 +1418,73 @@ mod tests { assert_eq!(back, d); assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets)); } + + #[test] + fn closing_earlier_inactive_tab_keeps_the_shown_panel() { + use PanelKind::*; + let mut d = tabs(&[Properties, Align, EffectsPresets], 1); + d.close(Properties); + assert!(d.is_visible(Align), "closing an inactive tab keeps the shown panel"); + assert!(!d.is_visible(EffectsPresets)); + assert_eq!(d.panel_count(), 2); + } + + #[test] + fn closing_active_tab_keeps_existing_neighbor_choice() { + use PanelKind::*; + for (active, closed, shown) in [(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align)] { + let mut d = tabs(&[Properties, Align, EffectsPresets], active); + d.close(closed); + assert!(d.is_visible(shown), "active {active}: {closed:?} -> {shown:?}"); + } + } + + #[test] + fn close_normalizes_invalid_active_and_collapses_empty_splits() { + use PanelKind::*; + let mut d = tabs(&[Properties, Align, EffectsPresets], usize::MAX); + d.close(History); + assert!(d.is_visible(EffectsPresets)); + d.close(Properties); + assert!(d.is_visible(EffectsPresets)); + d.close(EffectsPresets); + assert!(d.is_visible(Align)); + d.close(Align); + assert_eq!(d, tabs(&[], 0)); + let mut d = DockNode::Split { vertical: true, size: SplitSize::Ratio(0.5), a: Box::new(tabs(&[Properties], 0)), b: Box::new(tabs(&[Align], 0)) }; + d.close(Properties); + assert_eq!(d, tabs(&[Align], 0)); + d.close(Align); + assert_eq!(d, tabs(&[], 0)); + } + + #[test] + fn removing_earlier_inactive_floating_tab_keeps_the_shown_panel() { + use PanelKind::*; + let mut layout = + Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active: 1, rect: [0.0; 4] }] }; + assert!(layout.unfloat(Properties)); + let f = &layout.floating[0]; + assert_eq!(f.panels.get(f.active), Some(&Align)); + assert!(layout.root.is_visible(Composition)); + } + + #[test] + fn floating_removal_keeps_active_neighbor_and_handles_invalid_index() { + use PanelKind::*; + for (active, closed, shown) in + [(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align), (usize::MAX, Properties, EffectsPresets)] + { + let mut layout = + Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active, rect: [0.0; 4] }] }; + assert!(layout.unfloat(closed)); + let f = &layout.floating[0]; + assert_eq!(f.panels.get(f.active), Some(&shown)); + for p in [Properties, Align, EffectsPresets] { + layout.unfloat(p); + } + assert!(layout.floating.is_empty()); + assert!(!layout.unfloat(History)); + } + } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index e1784090..55c75f0b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -541,6 +541,42 @@ impl Frames { Some(img) } + /// Prefer the exact requested frame. While an edit renders, advance to the newest completed + /// revision of the same comp, time, scale, view and render options instead of freezing on the + /// image from before the drag. Input may arrive before the viewer draws on every UI frame, + /// so even a fast worker cannot always finish the *latest* revision in time. + /// + /// Never move backwards when workers finish out of order, and never substitute another time + /// or view while scrubbing or switching comps. The exact content lookup above also means an + /// undo can immediately reuse an old cache entry, regardless of its original revision. + pub fn get_for_viewer(&self, wanted: &FrameKey, shown: Option<&FrameKey>) -> Option<(FrameKey, FrameImage)> { + if let Some(img) = self.get(wanted) { + return Some((*wanted, img)); + } + let shown = shown?; + if shown.comp != wanted.comp || shown.frame != wanted.frame || shown.view != wanted.view || shown.revision >= wanted.revision { + return None; + } + let mut c = self.cache.lock().ok()?; + let newest = c + .map + .keys() + .filter(|k| { + k.revision > shown.revision + && k.revision < wanted.revision + && k.comp == wanted.comp + && k.frame == wanted.frame + && k.scale == wanted.scale + && k.view == wanted.view + && k.opts == wanted.opts + }) + .max_by_key(|k| k.revision) + .copied()?; + let img = c.map.get(&newest).cloned()?; + c.touch(&newest); + Some((newest, img)) + } + /// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per /// revision). The project is kept while frames of that identity are cached or rendering, so /// the comp addresses it hashes can't be reused by other comps meanwhile. @@ -665,6 +701,12 @@ impl Frames { c.bytes = 0; c.gpu_bytes = 0; } + // Disk keys hash the footage files' size and time, but are remembered per revision; a + // file reloaded from disk without a new revision (a re-rendered image sequence) must be + // keyed again, or the disk cache serves the frames from before. + if let Ok(mut k) = self.content_keys.lock() { + k.clear(); + } } /// Queue a prefetch frame (no-op if cached or already queued/rendering). @@ -698,8 +740,9 @@ impl Frames { // Only the newest viewer frame is urgent. The comp's earlier viewer frames that // haven't started are dropped (scrubbing: rendering every frame the pointer passed // would hold the workers the frame under it needs); frames already rendering finish - // into the cache, and the viewer shows its last frame until the new one is in - // (#284). Other comps' viewer frames become prefetch. + // into the cache. During edits the viewer advances through compatible completed + // revisions; scrubbing holds its last frame until the requested time is in (#284). + // Other comps' viewer frames become prefetch. q.jobs.retain(|j| { let superseded = j.urgent && j.key != key && j.key.comp == key.comp; if superseded { @@ -1267,6 +1310,53 @@ mod tests { assert!(!f.is_cached(&key(3, 0, 0))); } + #[test] + fn live_edits_present_the_newest_completed_revision_without_going_backwards() { + let f = Frames::default(); + let shown = key(1, 0, 0); + let newest = key(3, 0, 0); + let wanted = key(5, 0, 0); + insert(&f, shown); + insert(&f, newest); + insert(&f, key(2, 0, 0)); // Older worker finishes after the newer one. + let displayed = f.get_for_viewer(&wanted, Some(&shown)).unwrap().0; + assert_eq!(displayed, newest); + assert!(f.get_for_viewer(&wanted, Some(&displayed)).is_none(), "never replay an older completion"); + insert(&f, wanted); + assert_eq!(f.get_for_viewer(&wanted, Some(&displayed)).unwrap().0, wanted, "settle on the exact final edit"); + // Undo reuses the content of an earlier revision immediately. + let undo = FrameKey { revision: 6, ..shown }; + let reused = f.get_for_viewer(&undo, Some(&wanted)).unwrap().0; + assert_eq!(reused.content, shown.content); + assert_eq!(reused.revision, undo.revision); + } + + #[test] + fn live_edits_do_not_substitute_other_times_views_or_render_settings() { + let f = Frames::default(); + let shown = key(1, 0, 0); + let wanted = key(3, 0, 0); + let done = key(2, 0, 0); + for mismatch in [ + FrameKey { comp: 9, ..done }, + FrameKey { frame: 1, ..done }, + FrameKey { scale: 500, ..done }, + FrameKey { view: 1, ..done }, + FrameKey { opts: 1, ..done }, + FrameKey { revision: 4, ..done }, + ] { + f.clear(); + insert(&f, mismatch); + assert!(f.get_for_viewer(&wanted, Some(&shown)).is_none(), "wrong candidate: {mismatch:?}"); + } + f.clear(); + insert(&f, done); + assert!(f.get_for_viewer(&wanted, None).is_none(), "no stale content on opening a viewer"); + assert!(f.get_for_viewer(&wanted, Some(&FrameKey { comp: 9, ..shown })).is_none()); + assert!(f.get_for_viewer(&wanted, Some(&FrameKey { frame: 1, ..shown })).is_none()); + assert!(f.get_for_viewer(&wanted, Some(&FrameKey { view: 1, ..shown })).is_none()); + } + #[test] fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() { use effectcraft_engine::Session; diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 0ea37835..1b93658f 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -1,7 +1,7 @@ //! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community //! buttons (Discord is always one click away). -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -198,6 +198,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("header.{}", &id[4..]), r, tip); rx = r.min.x - 4.0; } + // Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next. + let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { + "auto" => (Icon::Monitor, tr("Sync with System")), + "light" => (Icon::Sun, tr("Light")), + _ => (Icon::Moon, tr("Dark")), + }; + let tip = tr_args("Appearance Mode: {}", &[&mode]); + let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0)); + if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() { + app.cycle_appearance(ui.ctx()); + } + app.auto.add("header.appearance", r, &tip); + rx = r.min.x - 4.0; rx -= 10.0; p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); rx -= 10.0; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04f00d30..4bbac2f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -11,7 +11,7 @@ use effectcraft_engine::menus::MenuEntry; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 4] = ["ja", "zh-hans", "zh-hant", "uk"]; +const CATALOG_CODES: [&str; 6] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -47,6 +47,16 @@ pub(crate) fn ukrainian(app: &EffectcraftApp) -> bool { language(app) == "uk" } +/// The UI is in Spanish. +pub(crate) fn spanish(app: &EffectcraftApp) -> bool { + language(app) == "es" +} + +/// The UI is in Brazilian Portuguese. +pub(crate) fn brazilian_portuguese(app: &EffectcraftApp) -> bool { + language(app) == "pt-br" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build stays in English because it has no system-locale probe. @@ -62,7 +72,7 @@ fn system_language() -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -71,6 +81,9 @@ fn supported(locale: Option<&str>) -> &'static str { match primary.as_str() { "ja" => "ja", "uk" => "uk", + "es" => "es", + // The only Portuguese catalog: Brazil's, also for Portugal rather than English. + "pt" => "pt-br", "zh" => { let traditional = rest.iter().any(|p| p.starts_with("hant") || p.starts_with("tw") || p.starts_with("hk") || p.starts_with("mo")); if traditional { "zh-hant" } else { "zh-hans" } @@ -80,11 +93,23 @@ fn supported(locale: Option<&str>) -> &'static str { } pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> &'a str { - match language(app) { + let language = language(app); + // The engine names the platform's file manager before the catalog sees the label. These + // two commands keep their ids, but on Windows/Linux their source is no longer "Finder". + if language == "uk" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Показати у Провіднику", + "Reveal in File Manager" => return "Показати у файловому менеджері", + _ => {} + } + } + match language { "ja" => lookup(JAPANESE, command, source), "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), "zh-hant" => lookup(TRADITIONAL_CHINESE, command, source), "uk" => lookup(UKRAINIAN, command, source), + "es" => lookup(SPANISH, command, source), + "pt-br" => lookup(PORTUGUESE, command, source), _ => source, } } @@ -103,6 +128,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "zh-hans" => ("撤销", "无可撤销的操作", "重做", "无可重做的操作"), "zh-hant" => ("復原", "沒有可復原的操作", "重做", "沒有可重做的操作"), "uk" => ("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення"), + "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), + "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), _ => return shown, }; match e.command.as_str() { @@ -169,6 +196,28 @@ pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> Stri return format!("為 3D 檢視“{name}”指定快速鍵"); } } + "es" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("Asignar método abreviado al espacio de trabajo “{name}”"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("Asignar método abreviado a la vista 3D “{name}”"); + } + } + "pt-br" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("Atribuir atalho à área de trabalho “{name}”"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("Atribuir atalho à visualização 3D “{name}”"); + } + } _ => {} } label(app, "", &shown).into() @@ -248,6 +297,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "查找缺失的效果"), ("file.findMissing", "Find Missing Fonts", "查找缺失的字体"), ("file.findMissing", "Find Missing Footage", "查找缺失的素材"), + ("file.relinkFootage", "Relink Missing Footage...", "重新链接缺失的素材..."), ("file.watchFolder", "Watch Folder...", "监视文件夹..."), ("", "Scripts", "脚本"), ("file.installScript", "Install Script File...", "安装脚本文件..."), @@ -871,6 +921,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "尋找遺失的效果"), ("file.findMissing", "Find Missing Fonts", "尋找遺失的字體"), ("file.findMissing", "Find Missing Footage", "尋找遺失的素材"), + ("file.relinkFootage", "Relink Missing Footage...", "重新連結遺失的素材..."), ("file.watchFolder", "Watch Folder...", "監視檔案夾..."), ("", "Scripts", "指令碼"), ("file.installScript", "Install Script File...", "安裝指令碼檔案..."), @@ -1491,6 +1542,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "見つからないエフェクトを検索"), ("file.findMissing", "Find Missing Fonts", "見つからないフォントを検索"), ("file.findMissing", "Find Missing Footage", "見つからないフッテージを検索"), + ("file.relinkFootage", "Relink Missing Footage...", "見つからないフッテージを再リンク..."), ("file.watchFolder", "Watch Folder...", "監視フォルダー..."), ("", "Scripts", "スクリプト"), ("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."), @@ -2114,6 +2166,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.findMissing", "Find Missing Effects", "Знайти відсутні ефекти"), ("file.findMissing", "Find Missing Fonts", "Знайти відсутні шрифти"), ("file.findMissing", "Find Missing Footage", "Знайти відсутні матеріали"), + ("file.relinkFootage", "Relink Missing Footage...", "Перепідключити відсутні матеріали..."), ("file.watchFolder", "Watch Folder...", "Спостерігати за текою..."), ("", "Scripts", "Скрипти"), ("file.installScript", "Install Script File...", "Встановити файл скрипту..."), @@ -2664,6 +2717,1254 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Відкрити демонстраційний проєкт"), ]; +/// Spanish (es): the same (command, English label) keys as `JAPANESE`, one entry per fixed menu +/// row of the engine's menu tree. +/// Wording follows Adobe's Spanish After Effects documentation (helpx.adobe.com/es) where it +/// names the feature, and macOS Spanish for the app menu. +const SPANISH: &[(&str, &str, &str)] = &[ + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "Acerca de EffectCraft..."), + ("", "Settings...", "Ajustes..."), + ("app.settings", "General...", "General..."), + ("app.settings", "Startup & Repair...", "Inicio y reparación..."), + ("app.settings", "Project...", "Proyecto..."), + ("app.settings", "Composition...", "Composición..."), + ("app.settings", "Previews...", "Previsualizaciones..."), + ("app.settings", "Appearance...", "Apariencia..."), + ("app.settings", "Grids & Guides...", "Cuadrículas y guías..."), + ("app.settings", "Labels...", "Etiquetas..."), + ("app.settings", "Type...", "Texto..."), + ("app.settings", "Import...", "Importar..."), + ("app.settings", "Export...", "Exportar..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Disco..."), + ("app.settings", "Memory & CPU...", "Memoria y CPU..."), + ("app.settings", "Video...", "Vídeo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Scripts y expresiones..."), + ("app.hide", "Hide EffectCraft", "Ocultar EffectCraft"), + ("app.hideOthers", "Hide Others", "Ocultar otros"), + ("app.showAll", "Show All", "Mostrar todo"), + ("app.quit", "Quit EffectCraft", "Salir de EffectCraft"), + ("", "File", "Archivo"), + ("", "New", "Nuevo"), + ("file.newProject", "New Project", "Nuevo proyecto"), + ("file.newFromTemplate", "New Project from Template...", "Nuevo proyecto a partir de plantilla..."), + ("project.newFolder", "New Folder", "Nueva carpeta"), + ("file.open", "Open Project...", "Abrir proyecto..."), + ("", "Open Recent", "Abrir proyecto reciente"), + ("file.clearRecent", "Clear Recent Projects", "Borrar proyectos recientes"), + ("file.close", "Close", "Cerrar"), + ("file.closeProject", "Close Project", "Cerrar proyecto"), + ("file.save", "Save", "Guardar"), + ("", "Save As", "Guardar como"), + ("file.saveAs", "Save As...", "Guardar como..."), + ("file.saveCopy", "Save a Copy...", "Guardar una copia..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Guardar una copia como XML..."), + ("templates.saveAs", "Save as Template...", "Guardar como plantilla..."), + ("file.incrementAndSave", "Increment and Save", "Incrementar y guardar"), + ("file.revert", "Revert", "Revertir"), + ("", "Import", "Importar"), + ("file.import", "File...", "Archivo..."), + ("file.importMultiple", "Multiple Files...", "Varios archivos..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Proyecto de Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Marcador de posición..."), + ("file.importSolid", "Solid...", "Sólido..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Punto de fuga (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Plantilla de Gráficos esenciales..."), + ("", "Import Recent Footage", "Importar material de archivo reciente"), + ("file.clearRecentFootage", "Clear Recent Footage", "Borrar material de archivo reciente"), + ("", "Export", "Exportar"), + ("renderQueue.add", "Add to Render Queue", "Añadir a la cola de procesamiento"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Proyecto de Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "JSON de Lottie..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Plantilla de Gráficos esenciales..."), + ("app.find", "Find", "Buscar"), + ("layer.addItem", "Add Footage to Comp", "Añadir material de archivo a la composición"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nueva composición a partir de la selección..."), + ("", "Dependencies", "Dependencias"), + ("file.collectFiles", "Collect Files...", "Recopilar archivos..."), + ("file.consolidateFootage", "Consolidate All Footage", "Consolidar todo el material de archivo"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Eliminar material de archivo no utilizado"), + ("file.reduceProject", "Reduce Project", "Reducir proyecto"), + ("file.findMissing", "Find Missing Effects", "Buscar efectos que faltan"), + ("file.findMissing", "Find Missing Fonts", "Buscar fuentes que faltan"), + ("file.findMissing", "Find Missing Footage", "Buscar material de archivo que falta"), + ("file.relinkFootage", "Relink Missing Footage...", "Volver a vincular material de archivo que falta..."), + ("file.watchFolder", "Watch Folder...", "Observar carpeta..."), + ("", "Scripts", "Scripts"), + ("file.installScript", "Install Script File...", "Instalar archivo de script..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar panel de ScriptUI..."), + ("file.runScript", "Run Script File...", "Ejecutar archivo de script..."), + ("", "Create Proxy", "Crear proxy"), + ("file.createProxy", "Still...", "Imagen fija..."), + ("file.createProxy", "Movie...", "Película..."), + ("", "Set Proxy", "Definir proxy"), + ("file.setProxy", "File...", "Archivo..."), + ("file.setProxyNone", "None", "Ninguno"), + ("", "Interpret Footage", "Interpretar material de archivo"), + ("file.interpretFootage", "Main...", "Principal..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Recordar interpretación"), + ("file.applyInterpretation", "Apply Interpretation", "Aplicar interpretación"), + ("", "Replace Footage", "Sustituir material de archivo"), + ("file.replaceFootage", "File...", "Archivo..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Marcador de posición..."), + ("file.replaceWithSolid", "Solid...", "Sólido..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Con composición en capas"), + ("file.reloadFootage", "Reload Footage", "Volver a cargar material de archivo"), + ("file.revealInFinder", "Reveal in Finder", "Mostrar en el Finder"), + ("file.projectSettings", "Project Settings...", "Configuración del proyecto..."), + ("app.quit", "Exit", "Salir"), + ("", "Edit", "Edición"), + ("edit.undo", "Undo", "Deshacer"), + ("edit.redo", "Redo", "Rehacer"), + ("", "History", "Historial"), + ("app.commandPalette", "Quick Apply...", "Aplicación rápida..."), + ("edit.cut", "Cut", "Cortar"), + ("edit.copy", "Copy", "Copiar"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Copiar con vínculos de propiedades"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Copiar con vínculos de propiedades relativos"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Copiar solo la expresión"), + ("edit.paste", "Paste", "Pegar"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Pegar fotogramas clave invertidos"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Pegar texto y ajustar formato"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Pegar solo el formato del texto"), + ("edit.clear", "Clear", "Borrar"), + ("edit.duplicate", "Duplicate", "Duplicar"), + ("edit.splitLayer", "Split Layer", "Dividir capa"), + ("edit.liftWorkArea", "Lift Work Area", "Elevar área de trabajo"), + ("edit.extractWorkArea", "Extract Work Area", "Extraer área de trabajo"), + ("edit.selectAll", "Select All", "Seleccionar todo"), + ("edit.deselectAll", "Deselect All", "Anular selección de todo"), + ("", "Label", "Etiqueta"), + ("edit.selectLabelGroup", "Select Label Group", "Seleccionar grupo de etiquetas"), + ("edit.label", "None", "Ninguna"), + ("edit.label", "Red", "Rojo"), + ("edit.label", "Yellow", "Amarillo"), + ("edit.label", "Aqua", "Agua"), + ("edit.label", "Pink", "Rosa"), + ("edit.label", "Lavender", "Lavanda"), + ("edit.label", "Peach", "Melocotón"), + ("edit.label", "Sea Foam", "Espuma de mar"), + ("edit.label", "Blue", "Azul"), + ("edit.label", "Green", "Verde"), + ("edit.label", "Purple", "Morado"), + ("edit.label", "Orange", "Naranja"), + ("edit.label", "Brown", "Marrón"), + ("edit.label", "Fuchsia", "Fucsia"), + ("edit.label", "Cyan", "Cian"), + ("edit.label", "Sandstone", "Arenisca"), + ("edit.label", "Dark Green", "Verde oscuro"), + ("app.settings", "Edit Label Colors...", "Editar colores de etiqueta..."), + ("", "Select Keyframe Label Group", "Seleccionar grupo de etiquetas de fotogramas clave"), + ("keys.selectLabelGroup", "On Selected Layers", "En las capas seleccionadas"), + ("keys.selectLabelGroup", "On All Layers", "En todas las capas"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Fotogramas clave visibles en las capas seleccionadas"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Fotogramas clave visibles en todas las capas"), + ("", "Purge", "Purgar"), + ("edit.purge", "All Cache...", "Toda la caché..."), + ("edit.purge", "All Memory & Disk Cache...", "Toda la memoria y la caché de disco..."), + ("edit.purge", "All Memory", "Toda la memoria"), + ("edit.purge", "All Disk Cache...", "Toda la caché de disco..."), + ("edit.purge", "All 3D Cache...", "Toda la caché 3D..."), + ("edit.purgeUndo", "Undo", "Deshacer"), + ("edit.purge", "Image Cache Memory", "Memoria de caché de imágenes"), + ("edit.purge", "Snapshot", "Instantánea"), + ("edit.editOriginal", "Edit Original...", "Editar original..."), + ("", "Templates", "Plantillas"), + ("app.templates", "Render Settings...", "Configuración de procesamiento..."), + ("app.templates", "Output Module...", "Módulo de salida..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Métodos abreviados de teclado"), + ("", "Preferences", "Preferencias"), + ("", "Composition", "Composición"), + ("comp.new", "New Composition...", "Nueva composición..."), + ("comp.settings", "Composition Settings...", "Configuración de la composición..."), + ("comp.setPosterTime", "Set Poster Time", "Establecer tiempo de póster"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Recortar composición al área de trabajo"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Recortar composición a la región de interés"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Recortar composición a los límites de las capas (seleccionadas)"), + ("render.addOutputModule", "Add Output Module", "Añadir módulo de salida"), + ("", "Preview", "Previsualización"), + ("playback.toggle", "Play Current Preview", "Reproducir la previsualización actual"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Almacenar fotogramas en caché en reposo"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Guardar fotograma como"), + ("comp.saveFrameAs", "File...", "Archivo..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Capas de Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Preprocesar..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Guardar la previsualización actual..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir en Gráficos esenciales"), + ("", "Responsive Design — Time", "Diseño adaptable — Tiempo"), + ("comp.responsiveTime", "Create Intro", "Crear introducción"), + ("comp.responsiveTime", "Create Outro", "Crear cierre"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Crear región protegida a partir del área de trabajo"), + ("comp.flowchart", "Composition Flowchart", "Diagrama de flujo de la composición"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Minidiagrama de flujo de la composición"), + ("", "VR", "RV"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Crear entorno de RV..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extraer mapa cúbico..."), + ("", "Layer", "Capa"), + ("layer.newText", "Text", "Texto"), + ("layer.newSolid", "Solid...", "Sólido..."), + ("layer.newLight", "Light...", "Luz..."), + ("layer.newCamera", "Camera...", "Cámara..."), + ("layer.newNull", "Null Object", "Objeto nulo"), + ("layer.newShape", "Shape Layer", "Capa de forma"), + ("layer.newAdjustment", "Adjustment Layer", "Capa de ajuste"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Capa de relleno según el contenido..."), + ("layer.new3dPrimitive", "Cube", "Cubo"), + ("layer.new3dPrimitive", "Sphere", "Esfera"), + ("layer.new3dPrimitive", "Plane", "Plano"), + ("layer.new3dPrimitive", "Torus", "Toroide"), + ("layer.new3dPrimitive", "Cone", "Cono"), + ("layer.new3dPrimitive", "Cylinder", "Cilindro"), + ("layer.settings", "Layer Settings...", "Configuración de la capa..."), + ("layer.openLayer", "Open Layer", "Abrir capa"), + ("layer.openSource", "Open Layer Source", "Abrir origen de la capa"), + ("layer.revealInFinder", "Reveal in Finder", "Mostrar en el Finder"), + ("", "Mask", "Máscara"), + ("layer.addMask", "New Mask", "Nueva máscara"), + ("layer.mask.shape", "Mask Shape...", "Forma de máscara..."), + ("layer.mask.set", "Mask Feather...", "Calado de máscara..."), + ("layer.mask.set", "Mask Opacity...", "Opacidad de máscara..."), + ("layer.mask.set", "Mask Expansion...", "Expansión de máscara..."), + ("layer.mask.reset", "Reset Mask", "Restablecer máscara"), + ("layer.mask.remove", "Remove Mask", "Quitar máscara"), + ("layer.mask.removeAll", "Remove All Masks", "Quitar todas las máscaras"), + ("track.mask", "Track Mask", "Seguir máscara"), + ("", "Mode", "Modo"), + ("layer.mask.mode", "None", "Ninguno"), + ("layer.mask.mode", "Add", "Añadir"), + ("layer.mask.mode", "Subtract", "Restar"), + ("layer.mask.mode", "Intersect", "Intersecar"), + ("layer.mask.mode", "Lighten", "Aclarar"), + ("layer.mask.mode", "Darken", "Oscurecer"), + ("layer.mask.mode", "Difference", "Diferencia"), + ("layer.mask.invert", "Inverted", "Invertida"), + ("layer.mask.lock", "Locked", "Bloqueada"), + ("", "Motion Blur", "Desenfoque de movimiento"), + ("layer.mask.motionBlur", "Same As Layer", "Igual que la capa"), + ("layer.mask.motionBlur", "On", "Activado"), + ("layer.mask.motionBlur", "Off", "Desactivado"), + ("", "Feather Falloff", "Atenuación del calado"), + ("layer.mask.featherFalloff", "Smooth", "Suave"), + ("layer.mask.featherFalloff", "Linear", "Lineal"), + ("layer.mask.unlockAll", "Unlock All Masks", "Desbloquear todas las máscaras"), + ("layer.mask.lockOthers", "Lock Other Masks", "Bloquear las demás máscaras"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), + ("", "Mask and Shape Path", "Trazado de máscara y de forma"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Cerrado"), + ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trazado Bézier"), + ("path.setFirstVertex", "Set First Vertex", "Establecer primer vértice"), + ("path.freeTransform", "Free Transform Points", "Transformar puntos libremente"), + ("", "Quality", "Calidad"), + ("layer.quality", "Best", "Máxima"), + ("layer.quality", "Draft", "Borrador"), + ("layer.quality", "Wireframe", "Estructura alámbrica"), + ("layer.sampling", "Bilinear", "Bilineal"), + ("layer.sampling", "Bicubic", "Bicúbico"), + ("", "Switches", "Conmutadores"), + ("layer.hideOtherVideo", "Hide Other Video", "Ocultar el resto del vídeo"), + ("layer.showAllVideo", "Show All Video", "Mostrar todo el vídeo"), + ("layer.unlockAll", "Unlock All Layers", "Desbloquear todas las capas"), + ("layer.expressions", "Enable Expressions", "Habilitar expresiones"), + ("layer.expressions", "Disable Expressions", "Deshabilitar expresiones"), + ("layer.setSwitch", "Shy", "Ocultar"), + ("layer.setSwitch", "Lock", "Bloquear"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Vídeo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efecto"), + ("layer.setSwitch", "Collapse", "Contraer"), + ("layer.setSwitch", "Motion Blur", "Desenfoque de movimiento"), + ("layer.setSwitch", "Adjustment Layer", "Capa de ajuste"), + ("", "Transform", "Transformar"), + ("layer.transform", "Reset", "Restablecer"), + ("layer.setTransform", "Anchor Point...", "Punto de anclaje..."), + ("layer.setTransform", "Position...", "Posición..."), + ("layer.setTransform", "Scale...", "Escala..."), + ("layer.setTransform", "Orientation...", "Orientación..."), + ("layer.setTransform", "Rotation...", "Rotación..."), + ("layer.setTransform", "Opacity...", "Opacidad..."), + ("layer.transform", "Flip Horizontal", "Voltear horizontalmente"), + ("layer.transform", "Flip Vertical", "Voltear verticalmente"), + ("layer.transform", "Center In View", "Centrar en la vista"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Centrar el punto de anclaje en el contenido de la capa"), + ("layer.transform", "Fit to Comp", "Ajustar a la composición"), + ("layer.transform", "Fit to Comp Width", "Ajustar al ancho de la composición"), + ("layer.transform", "Fit to Comp Height", "Ajustar al alto de la composición"), + ("layer.autoOrient", "Auto-Orient...", "Orientación automática..."), + ("", "Time", "Tiempo"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Habilitar reasignación de tiempo"), + ("layer.timeReverse", "Time-Reverse Layer", "Invertir el tiempo de la capa"), + ("layer.timeStretch", "Time Stretch...", "Estirar el tiempo..."), + ("layer.freezeFrame", "Freeze Frame", "Congelar fotograma"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Congelar en el último fotograma"), + ("layer.alignVideoToData", "Align Video to Data", "Alinear el vídeo con los datos"), + ("", "Frame Blending", "Fusión de fotogramas"), + ("layer.frameBlending", "Off", "Desactivada"), + ("layer.frameBlending", "Frame Mix", "Mezcla de fotogramas"), + ("layer.frameBlending", "Pixel Motion", "Movimiento de píxeles"), + ("layer.setSwitch", "3D Layer", "Capa 3D"), + ("layer.setSwitch", "Guide Layer", "Capa guía"), + ("layer.environment", "Environment Layer", "Capa de entorno"), + ("", "Markers", "Marcadores"), + ("layer.addMarker", "Add Marker", "Añadir marcador"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Actualizar marcadores desde el origen"), + ("layer.markersLock", "Lock Markers", "Bloquear marcadores"), + ("layer.deleteAllMarkers", "Delete All Markers", "Eliminar todos los marcadores"), + ("layer.setSwitch", "Preserve Transparency", "Conservar transparencia"), + ("", "Blending Mode", "Modo de fusión"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Disolver"), + ("layer.setBlendMode", "Dancing Dissolve", "Disolver con variación"), + ("layer.setBlendMode", "Darken", "Oscurecer"), + ("layer.setBlendMode", "Multiply", "Multiplicar"), + ("layer.setBlendMode", "Color Burn", "Subexposición de color"), + ("layer.setBlendMode", "Classic Color Burn", "Subexposición de color clásico"), + ("layer.setBlendMode", "Linear Burn", "Subexposición lineal"), + ("layer.setBlendMode", "Darker Color", "Color más oscuro"), + ("layer.setBlendMode", "Add", "Añadir"), + ("layer.setBlendMode", "Lighten", "Aclarar"), + ("layer.setBlendMode", "Screen", "Pantalla"), + ("layer.setBlendMode", "Color Dodge", "Sobreexposición de color"), + ("layer.setBlendMode", "Classic Color Dodge", "Sobreexposición de color clásico"), + ("layer.setBlendMode", "Linear Dodge", "Sobreexposición lineal"), + ("layer.setBlendMode", "Lighter Color", "Color más claro"), + ("layer.setBlendMode", "Overlay", "Superponer"), + ("layer.setBlendMode", "Soft Light", "Luz suave"), + ("layer.setBlendMode", "Hard Light", "Luz fuerte"), + ("layer.setBlendMode", "Linear Light", "Luz lineal"), + ("layer.setBlendMode", "Vivid Light", "Luz intensa"), + ("layer.setBlendMode", "Pin Light", "Luz focal"), + ("layer.setBlendMode", "Hard Mix", "Mezcla dura"), + ("layer.setBlendMode", "Difference", "Diferencia"), + ("layer.setBlendMode", "Classic Difference", "Diferencia clásica"), + ("layer.setBlendMode", "Exclusion", "Exclusión"), + ("layer.setBlendMode", "Subtract", "Restar"), + ("layer.setBlendMode", "Divide", "Dividir"), + ("layer.setBlendMode", "Hue", "Tono"), + ("layer.setBlendMode", "Saturation", "Saturación"), + ("layer.setBlendMode", "Color", "Color"), + ("layer.setBlendMode", "Luminosity", "Luminosidad"), + ("layer.setBlendMode", "Stencil Alpha", "Esténcil alfa"), + ("layer.setBlendMode", "Stencil Luma", "Esténcil luminancia"), + ("layer.setBlendMode", "Silhouette Alpha", "Silueta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Silueta luminancia"), + ("layer.setBlendMode", "Alpha Add", "Adición de alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Premul luminescente"), + ("layer.setBlendMode", "Next Blending Mode", "Modo de fusión siguiente"), + ("layer.setBlendMode", "Previous Blending Mode", "Modo de fusión anterior"), + ("", "Track Matte", "Mate de seguimiento"), + ("layer.trackMatte", "No Track Matte", "Sin mate de seguimiento"), + ("layer.trackMatte", "Alpha Matte", "Mate alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Mate alfa invertido"), + ("layer.trackMatte", "Luma Matte", "Mate de luminancia"), + ("layer.trackMatte", "Luma Inverted Matte", "Mate de luminancia invertido"), + ("layer.trackMatte", "Matte with Layer Above", "Mate con la capa superior"), + ("layer.trackMatte", "Matte with Layer Below", "Mate con la capa inferior"), + ("", "Layer Styles", "Estilos de capa"), + ("layer.style.options", "Layer Style Options...", "Opciones de estilo de capa..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Convertir en estilos editables"), + ("layer.style.showAll", "Show All", "Mostrar todo"), + ("layer.style.removeAll", "Remove All", "Quitar todo"), + ("layer.style.dropShadow", "Drop Shadow", "Sombra paralela"), + ("layer.style.innerShadow", "Inner Shadow", "Sombra interior"), + ("layer.style.outerGlow", "Outer Glow", "Resplandor exterior"), + ("layer.style.innerGlow", "Inner Glow", "Resplandor interior"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Bisel y relieve"), + ("layer.style.satin", "Satin", "Satinado"), + ("layer.style.colorOverlay", "Color Overlay", "Superposición de color"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Superposición de degradado"), + ("layer.style.stroke", "Stroke", "Trazo"), + ("path.groupShapes", "Group Shapes", "Agrupar formas"), + ("path.ungroupShapes", "Ungroup Shapes", "Desagrupar formas"), + ("", "Arrange", "Organizar"), + ("layer.arrange", "Bring Layer to Front", "Traer capa al frente"), + ("layer.arrange", "Bring Layer Forward", "Subir capa"), + ("layer.arrange", "Send Layer Backward", "Bajar capa"), + ("layer.arrange", "Send Layer to Back", "Enviar capa al fondo"), + ("", "Reveal", "Mostrar"), + ("layer.revealSource", "Reveal Layer Source in Project", "Mostrar el origen de la capa en el proyecto"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Mostrar la capa en el diagrama de flujo del proyecto"), + ("comp.revealInProject", "Reveal Composition in Project", "Mostrar la composición en el proyecto"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Mostrar errores de expresión"), + ("", "Create", "Crear"), + ("layer.create", "Convert to Editable Text", "Convertir en texto editable"), + ("layer.create", "Create Shapes from Text", "Crear formas a partir de texto"), + ("layer.create", "Create Masks from Text", "Crear máscaras a partir de texto"), + ("layer.create", "Create Shapes from Vector Layer", "Crear formas a partir de capa vectorial"), + ("layer.create", "Create Keyframes from Data", "Crear fotogramas clave a partir de datos"), + ("layer.create", "Null Controllers for Positional Points", "Controladores nulos para puntos de posición"), + ("layer.create", "Null Controllers for Path Points", "Controladores nulos para puntos de trazado"), + ("layer.create", "Nulls Following Path Points", "Nulos que siguen los puntos del trazado"), + ("layer.create", "Null Tracing Along Path", "Rastreo nulo a lo largo del trazado"), + ("layer.create", "Create 3D Layer Instance", "Crear instancia de capa 3D"), + ("", "Camera", "Cámara"), + ("camera.fromView", "Create Camera from 3D View", "Crear cámara a partir de la vista 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Crear plataforma 3D estéreo"), + ("camera.orbitNull", "Create Orbit Null", "Crear nulo orbital"), + ("camera.fromModel", "Create Cameras from 3D Model", "Crear cámaras a partir de modelo 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Vincular la distancia de enfoque al punto de interés"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Vincular la distancia de enfoque a la capa"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Establecer la distancia de enfoque en la capa"), + ("layer.cameraSettings", "Camera Settings...", "Configuración de la cámara..."), + ("view.reset3DView", "Reset 3D View", "Restablecer vista 3D"), + ("", "Light", "Luz"), + ("light.fromModel", "Create Lights from 3D Model", "Crear luces a partir de modelo 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Controlar la luz con la cámara"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Crear capa de fondo de luz de entorno"), + ("", "Material", "Material"), + ("material.revealSource", "Reveal Material Source in Project", "Mostrar el origen del material en el proyecto"), + ("material.reset", "Reset Material", "Restablecer material"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Duplicar y asignar material"), + ("layer.autoTrace", "Auto-trace...", "Autotrazado..."), + ("layer.precompose", "Pre-compose...", "Precomponer..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Detección de edición de escena..."), + ("", "Effect", "Efecto"), + ("window.panel", "Effect Controls", "Controles de efectos"), + ("effect.applyLast", "Last Effect", "Último efecto"), + ("effect.removeAll", "Remove All", "Quitar todo"), + ("effect.manage", "Manage Effects...", "Administrar efectos..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Cargar complemento de efecto..."), + ("", "Animation", "Animación"), + ("anim.savePreset", "Save Animation Preset...", "Guardar ajuste preestablecido de animación..."), + ("anim.applyPreset", "Apply Animation Preset...", "Aplicar ajuste preestablecido de animación..."), + ("", "Recent Animation Presets", "Ajustes preestablecidos de animación recientes"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Borrar ajustes preestablecidos recientes"), + ("anim.browsePresets", "Browse Presets...", "Examinar ajustes preestablecidos..."), + ("", "Text Animation Presets", "Ajustes preestablecidos de animación de texto"), + ("layer.applyTextPreset", "Typewriter", "Máquina de escribir"), + ("layer.applyTextPreset", "Fade Up Characters", "Aparición de caracteres hacia arriba"), + ("layer.applyTextPreset", "Bounce In Words", "Entrada de palabras con rebote"), + ("layer.applyTextPreset", "Tracking In", "Entrada de espaciado"), + ("layer.applyTextPreset", "Scramble", "Mezclar"), + ("layer.applyTextPreset", "Blur In", "Entrada con desenfoque"), + ("layer.applyTextPreset", "Jitter", "Vibración"), + ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), + ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), + ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), + ("", "Keyframe Assistant", "Asistente de fotogramas clave"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Convertir audio en fotogramas clave"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Convertir expresión en fotogramas clave"), + ("keys.easyEase", "Easy Ease", "Suavizado"), + ("keys.easyEaseIn", "Easy Ease In", "Suavizado de entrada"), + ("keys.easyEaseOut", "Easy Ease Out", "Suavizado de salida"), + ("keys.exponentialScale", "Exponential Scale", "Escala exponencial"), + ("keys.rpfCameraImport", "RPF Camera Import", "Importación de cámara RPF"), + ("layer.sequence", "Sequence Layers...", "Secuenciar capas..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Invertir el tiempo de los fotogramas clave"), + ("", "Animate Text", "Animar texto"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Habilitar 3D por carácter"), + ("layer.addTextAnimator", "Anchor Point", "Punto de anclaje"), + ("layer.addTextAnimator", "Position", "Posición"), + ("layer.addTextAnimator", "Scale", "Escala"), + ("layer.addTextAnimator", "Skew", "Inclinación"), + ("layer.addTextAnimator", "Rotation", "Rotación"), + ("layer.addTextAnimator", "Opacity", "Opacidad"), + ("layer.addTextAnimator", "All Transform Properties", "Todas las propiedades de transformación"), + ("", "Fill Color", "Color de relleno"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Tono"), + ("layer.addTextAnimator", "Saturation", "Saturación"), + ("layer.addTextAnimator", "Brightness", "Brillo"), + ("", "Stroke Color", "Color de trazo"), + ("layer.addTextAnimator", "Stroke Width", "Ancho de trazo"), + ("layer.addTextAnimator", "Tracking", "Espaciado"), + ("layer.addTextAnimator", "Line Anchor", "Anclaje de línea"), + ("layer.addTextAnimator", "Line Spacing", "Interlineado"), + ("layer.addTextAnimator", "Character Offset", "Desplazamiento de caracteres"), + ("layer.addTextAnimator", "Character Value", "Valor de carácter"), + ("layer.addTextAnimator", "Blur", "Desenfoque"), + ("text.animatorFontAxes", "Variable Font Axes", "Ejes de fuente variable"), + ("", "Add Text Selector", "Añadir selector de texto"), + ("text.addSelector", "Range", "Rango"), + ("text.addSelector", "Wiggly", "Ondulante"), + ("text.addSelector", "Expression", "Expresión"), + ("text.removeAllAnimators", "Remove All Text Animators", "Quitar todos los animadores de texto"), + ("prop.setExpression", "Add Expression", "Añadir expresión"), + ("essential.addProperty", "Add Property to Essential Graphics", "Añadir propiedad a Gráficos esenciales"), + ("prop.separateDimensions", "Separate Dimensions", "Separar dimensiones"), + ("track.camera", "Track Camera", "Seguir cámara"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Estabilizador de deformación VFX"), + ("track.motion", "Track Motion", "Seguir movimiento"), + ("track.stabilize", "Stabilize Motion", "Estabilizar movimiento"), + ("track.property", "Track this Property", "Seguir esta propiedad"), + ("anim.reveal", "Reveal Properties with Keyframes", "Mostrar propiedades con fotogramas clave"), + ("anim.reveal", "Reveal Properties with Animation", "Mostrar propiedades con animación"), + ("anim.reveal", "Reveal All Modified Properties", "Mostrar todas las propiedades modificadas"), + ("", "View", "Ver"), + ("view.newViewer", "New Viewer", "Nuevo visor"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Dividir con nuevo visor bloqueado"), + ("view.zoomIn", "Zoom In", "Acercar"), + ("view.zoomOut", "Zoom Out", "Alejar"), + ("", "Resolution", "Resolución"), + ("view.res.full", "Full", "Completa"), + ("view.res.half", "Half", "Mitad"), + ("view.res.third", "Third", "Tercio"), + ("view.res.quarter", "Quarter", "Cuarto"), + ("view.res.custom", "Custom...", "Personalizada..."), + ("view.displayColorManagement", "Use Display Color Management", "Usar gestión del color de la pantalla"), + ("", "Simulate Output", "Simular salida"), + ("view.simulateOutput", "No Output Simulation", "Sin simulación de salida"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "RGB de Macintosh heredado (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "RGB estándar de Internet (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Lineal (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Mi RGB personalizado..."), + ("view.simulateOutput", "Custom...", "Personalizada..."), + ("view.rulers", "Show Rulers", "Mostrar reglas"), + ("", "Panel Background Color", "Color de fondo del panel"), + ("view.panelBackground", "Black", "Negro"), + ("view.panelBackground", "Dark Gray", "Gris oscuro"), + ("view.panelBackground", "Medium Gray (Default)", "Gris medio (predeterminado)"), + ("view.panelBackground", "Light Gray", "Gris claro"), + ("view.panelBackground", "White", "Blanco"), + ("view.panelBackground", "Custom", "Personalizado"), + ("view.panelBackground", "Select Custom Background Color...", "Seleccionar color de fondo personalizado..."), + ("view.guides", "Show Guides", "Mostrar guías"), + ("view.snapToGuides", "Snap to Guides", "Ajustar a las guías"), + ("view.lockGuides", "Lock Guides", "Bloquear guías"), + ("view.addGuide", "Add Guide...", "Añadir guía..."), + ("view.clearGuides", "Clear Guides", "Borrar guías"), + ("view.importGuides", "Import Guides...", "Importar guías..."), + ("view.exportGuides", "Export Guides...", "Exportar guías..."), + ("view.grid", "Show Grid", "Mostrar cuadrícula"), + ("view.snapToGrid", "Snap to Grid", "Ajustar a la cuadrícula"), + ("view.snapping", "Snapping", "Ajuste"), + ("view.options", "View Options...", "Opciones de vista..."), + ("view.layerControls", "Show Layer Controls", "Mostrar controles de capa"), + ("", "Switch View Layout", "Cambiar diseño de vista"), + ("view.layout", "1 View", "1 vista"), + ("view.layout", "2 Views", "2 vistas"), + ("view.layout", "4 Views", "4 vistas"), + ("view.shareViewOptions", "Share View Options", "Compartir opciones de vista"), + ("", "Switch 3D View", "Cambiar vista 3D"), + ("view.3d.activeCamera", "Active Camera", "Cámara activa"), + ("view.3d.default", "Default", "Predeterminada"), + ("view.3d.front", "Front", "Frontal"), + ("view.3d.left", "Left", "Izquierda"), + ("view.3d.top", "Top", "Superior"), + ("view.3d.back", "Back", "Posterior"), + ("view.3d.right", "Right", "Derecha"), + ("view.3d.bottom", "Bottom", "Inferior"), + ("view.3d.custom1", "Custom View 1", "Vista personalizada 1"), + ("view.3d.custom2", "Custom View 2", "Vista personalizada 2"), + ("view.3d.custom3", "Custom View 3", "Vista personalizada 3"), + ("", "Assign Shortcut to 3D View", "Asignar método abreviado a la vista 3D"), + ("view.3d.last", "Switch to Last 3D View", "Cambiar a la última vista 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Mirar las capas seleccionadas"), + ("view.lookAtAll", "Look at All Layers", "Mirar todas las capas"), + ("time.set", "Go to Time...", "Ir a tiempo..."), + ("view.fullScreen", "Enter Full Screen", "Entrar en pantalla completa"), + ("", "Window", "Ventana"), + ("", "Workspace", "Espacio de trabajo"), + ("window.workspace", "Default", "Predeterminado"), + ("window.workspace", "Review", "Revisión"), + ("window.workspace", "Learn", "Aprendizaje"), + ("window.workspace", "Small Screen", "Pantalla pequeña"), + ("window.workspace", "Standard", "Estándar"), + ("window.workspace", "All Panels", "Todos los paneles"), + ("window.workspace", "Animation", "Animación"), + ("window.workspace", "Color", "Color"), + ("window.workspace", "Effects", "Efectos"), + ("window.workspace", "Essential Graphics", "Gráficos esenciales"), + ("window.workspace", "Minimal", "Mínimo"), + ("window.workspace", "Motion Tracking", "Seguimiento de movimiento"), + ("window.workspace", "Paint", "Pintura"), + ("window.workspace", "Text", "Texto"), + ("window.workspace", "Undocked Panels", "Paneles no acoplados"), + ("window.resetWorkspace", "Reset to Saved Layout", "Restablecer al diseño guardado"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Guardar cambios en este espacio de trabajo"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Guardar como nuevo espacio de trabajo..."), + ("window.editWorkspaces", "Edit Workspaces...", "Editar espacios de trabajo..."), + ("", "Assign Shortcut to Workspace", "Asignar método abreviado al espacio de trabajo"), + ("window.panel", "Align", "Alinear"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pinceles"), + ("window.panel", "Character", "Carácter"), + ("window.panel", "Content-Aware Fill", "Relleno según el contenido"), + ("window.panel", "Effects & Presets", "Efectos y ajustes preestablecidos"), + ("window.panel", "Essential Graphics", "Gráficos esenciales"), + ("window.panel", "Info", "Información"), + ("help.inAppTutorials", "Learn", "Aprender"), + ("window.panel", "Lumetri Scopes", "Ámbitos de Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolación de máscara"), + ("window.panel", "Media Browser", "Navegador de medios"), + ("window.panel", "Metadata", "Metadatos"), + ("window.panel", "Motion Sketch", "Boceto de movimiento"), + ("window.panel", "Paint", "Pintura"), + ("window.panel", "Paragraph", "Párrafo"), + ("window.panel", "Preview", "Previsualización"), + ("window.panel", "Progress", "Progreso"), + ("window.panel", "Properties", "Propiedades"), + ("window.panel", "Script Console", "Consola de scripts"), + ("window.panel", "Smoother", "Suavizador"), + ("window.panel", "Tools", "Herramientas"), + ("window.panel", "Tracker", "Rastreador"), + ("window.panel", "Wiggler", "Oscilador"), + ("window.panel", "Composition", "Composición"), + ("window.panel", "Flowchart", "Diagrama de flujo"), + ("window.panel", "Footage", "Material de archivo"), + ("window.panel", "Layer", "Capa"), + ("window.panel", "Project", "Proyecto"), + ("window.panel", "Render Queue", "Cola de procesamiento"), + ("window.panel", "Timeline", "Cronología"), + ("window.panel", "Create Nulls From Paths", "Crear nulos a partir de trazados"), + ("window.panel", "VR Comp Editor", "Editor de composición de RV"), + ("", "Help", "Ayuda"), + ("help.docs", "EffectCraft Help...", "Ayuda de EffectCraft..."), + ("help.docs", "Scripting Help...", "Ayuda de scripts..."), + ("help.docs", "Expression Reference...", "Referencia de expresiones..."), + ("help.docs", "Effect Reference...", "Referencia de efectos..."), + ("anim.browsePresets", "Animation Presets...", "Ajustes preestablecidos de animación..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Métodos abreviados de teclado..."), + ("help.inAppTutorials", "In-App Tutorials...", "Tutoriales en la aplicación..."), + ("help.onlineTutorials", "Online Tutorials...", "Tutoriales en línea..."), + ("help.systemReport", "System Compatibility Report...", "Informe de compatibilidad del sistema..."), + ("help.enableLogging", "Enable Logging", "Habilitar registro"), + ("help.revealLogFile", "Reveal Logging File", "Mostrar archivo de registro"), + ("help.discord", "Join the ArtCraft Discord...", "Unirse al Discord de ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Enviar comentarios..."), + ("help.website", "ArtCraft Website", "Sitio web de ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Página de inicio de EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft en GitHub"), + ("file.openDemoProject", "Open Demo Project", "Abrir proyecto de demostración"), +]; + +/// Brazilian Portuguese: the same (command, English label) keys as `JAPANESE`, one entry +/// per fixed menu row of the engine's menu tree. +const PORTUGUESE: &[(&str, &str, &str)] = &[ + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "Sobre o EffectCraft..."), + ("", "Settings...", "Configurações..."), + ("app.settings", "General...", "Geral..."), + ("app.settings", "Startup & Repair...", "Inicialização e reparo..."), + ("app.settings", "Project...", "Projeto..."), + ("app.settings", "Composition...", "Composição..."), + ("app.settings", "Previews...", "Pré-visualizações..."), + ("app.settings", "Appearance...", "Aparência..."), + ("app.settings", "Grids & Guides...", "Grade e guias..."), + ("app.settings", "Labels...", "Rótulos..."), + ("app.settings", "Type...", "Texto..."), + ("app.settings", "Import...", "Importação..."), + ("app.settings", "Export...", "Exportação..."), + ("app.settings", "Audio...", "Áudio..."), + ("app.settings", "Disk...", "Disco..."), + ("app.settings", "Memory & CPU...", "Memória e CPU..."), + ("app.settings", "Video...", "Vídeo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Scripts e expressões..."), + ("app.hide", "Hide EffectCraft", "Ocultar EffectCraft"), + ("app.hideOthers", "Hide Others", "Ocultar outros"), + ("app.showAll", "Show All", "Mostrar tudo"), + ("app.quit", "Quit EffectCraft", "Sair do EffectCraft"), + ("", "File", "Arquivo"), + ("", "New", "Novo"), + ("file.newProject", "New Project", "Novo projeto"), + ("file.newFromTemplate", "New Project from Template...", "Novo projeto a partir de modelo..."), + ("project.newFolder", "New Folder", "Nova pasta"), + ("file.open", "Open Project...", "Abrir projeto..."), + ("", "Open Recent", "Abrir recentes"), + ("file.clearRecent", "Clear Recent Projects", "Limpar projetos recentes"), + ("file.close", "Close", "Fechar"), + ("file.closeProject", "Close Project", "Fechar projeto"), + ("file.save", "Save", "Salvar"), + ("", "Save As", "Salvar como"), + ("file.saveAs", "Save As...", "Salvar como..."), + ("file.saveCopy", "Save a Copy...", "Salvar uma cópia..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Salvar uma cópia como XML..."), + ("templates.saveAs", "Save as Template...", "Salvar como modelo..."), + ("file.incrementAndSave", "Increment and Save", "Incrementar e salvar"), + ("file.revert", "Revert", "Reverter"), + ("", "Import", "Importar"), + ("file.import", "File...", "Arquivo..."), + ("file.importMultiple", "Multiple Files...", "Vários arquivos..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projeto do Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Espaço reservado..."), + ("file.importSolid", "Solid...", "Sólido..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Modelo de gráficos essenciais..."), + ("", "Import Recent Footage", "Importar mídias recentes"), + ("file.clearRecentFootage", "Clear Recent Footage", "Limpar mídias recentes"), + ("", "Export", "Exportar"), + ("renderQueue.add", "Add to Render Queue", "Adicionar à fila de renderização"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projeto do Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Modelo de gráficos essenciais..."), + ("app.find", "Find", "Localizar"), + ("layer.addItem", "Add Footage to Comp", "Adicionar mídia à composição"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nova composição a partir da seleção..."), + ("", "Dependencies", "Dependências"), + ("file.collectFiles", "Collect Files...", "Coletar arquivos..."), + ("file.consolidateFootage", "Consolidate All Footage", "Consolidar todas as mídias"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Remover mídias não utilizadas"), + ("file.reduceProject", "Reduce Project", "Reduzir projeto"), + ("file.findMissing", "Find Missing Effects", "Localizar efeitos ausentes"), + ("file.findMissing", "Find Missing Fonts", "Localizar fontes ausentes"), + ("file.findMissing", "Find Missing Footage", "Localizar mídias ausentes"), + ("file.relinkFootage", "Relink Missing Footage...", "Revincular mídias ausentes..."), + ("file.watchFolder", "Watch Folder...", "Pasta monitorada..."), + ("", "Scripts", "Scripts"), + ("file.installScript", "Install Script File...", "Instalar arquivo de script..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar painel ScriptUI..."), + ("file.runScript", "Run Script File...", "Executar arquivo de script..."), + ("", "Create Proxy", "Criar proxy"), + ("file.createProxy", "Still...", "Imagem fixa..."), + ("file.createProxy", "Movie...", "Filme..."), + ("", "Set Proxy", "Definir proxy"), + ("file.setProxy", "File...", "Arquivo..."), + ("file.setProxyNone", "None", "Nenhum"), + ("", "Interpret Footage", "Interpretar mídia"), + ("file.interpretFootage", "Main...", "Principal..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Lembrar interpretação"), + ("file.applyInterpretation", "Apply Interpretation", "Aplicar interpretação"), + ("", "Replace Footage", "Substituir mídia"), + ("file.replaceFootage", "File...", "Arquivo..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Espaço reservado..."), + ("file.replaceWithSolid", "Solid...", "Sólido..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Por composição com camadas"), + ("file.reloadFootage", "Reload Footage", "Recarregar mídia"), + ("file.revealInFinder", "Reveal in Finder", "Mostrar no Finder"), + ("file.projectSettings", "Project Settings...", "Configurações do projeto..."), + ("app.quit", "Exit", "Sair"), + ("", "Edit", "Editar"), + ("edit.undo", "Undo", "Desfazer"), + ("edit.redo", "Redo", "Refazer"), + ("", "History", "Histórico"), + ("app.commandPalette", "Quick Apply...", "Aplicação rápida..."), + ("edit.cut", "Cut", "Recortar"), + ("edit.copy", "Copy", "Copiar"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Copiar com vínculos de propriedade"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Copiar com vínculos de propriedade relativos"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Copiar apenas a expressão"), + ("edit.paste", "Paste", "Colar"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Colar quadros-chave em ordem inversa"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Colar texto e manter a formatação"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Colar apenas a formatação do texto"), + ("edit.clear", "Clear", "Limpar"), + ("edit.duplicate", "Duplicate", "Duplicar"), + ("edit.splitLayer", "Split Layer", "Dividir camada"), + ("edit.liftWorkArea", "Lift Work Area", "Retirar área de trabalho"), + ("edit.extractWorkArea", "Extract Work Area", "Extrair área de trabalho"), + ("edit.selectAll", "Select All", "Selecionar tudo"), + ("edit.deselectAll", "Deselect All", "Desmarcar tudo"), + ("", "Label", "Rótulo"), + ("edit.selectLabelGroup", "Select Label Group", "Selecionar grupo de rótulos"), + ("edit.label", "None", "Nenhum"), + ("edit.label", "Red", "Vermelho"), + ("edit.label", "Yellow", "Amarelo"), + ("edit.label", "Aqua", "Aqua"), + ("edit.label", "Pink", "Rosa"), + ("edit.label", "Lavender", "Lavanda"), + ("edit.label", "Peach", "Pêssego"), + ("edit.label", "Sea Foam", "Espuma do mar"), + ("edit.label", "Blue", "Azul"), + ("edit.label", "Green", "Verde"), + ("edit.label", "Purple", "Roxo"), + ("edit.label", "Orange", "Laranja"), + ("edit.label", "Brown", "Marrom"), + ("edit.label", "Fuchsia", "Fúcsia"), + ("edit.label", "Cyan", "Ciano"), + ("edit.label", "Sandstone", "Arenito"), + ("edit.label", "Dark Green", "Verde escuro"), + ("app.settings", "Edit Label Colors...", "Editar cores dos rótulos..."), + ("", "Select Keyframe Label Group", "Selecionar grupo de rótulos de quadros-chave"), + ("keys.selectLabelGroup", "On Selected Layers", "Nas camadas selecionadas"), + ("keys.selectLabelGroup", "On All Layers", "Em todas as camadas"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Quadros-chave visíveis nas camadas selecionadas"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Quadros-chave visíveis em todas as camadas"), + ("", "Purge", "Limpar cache"), + ("edit.purge", "All Cache...", "Todo o cache..."), + ("edit.purge", "All Memory & Disk Cache...", "Toda a memória e o cache do disco..."), + ("edit.purge", "All Memory", "Toda a memória"), + ("edit.purge", "All Disk Cache...", "Todo o cache do disco..."), + ("edit.purge", "All 3D Cache...", "Todo o cache 3D..."), + ("edit.purgeUndo", "Undo", "Desfazer"), + ("edit.purge", "Image Cache Memory", "Memória de cache de imagens"), + ("edit.purge", "Snapshot", "Instantâneo"), + ("edit.editOriginal", "Edit Original...", "Editar original..."), + ("", "Templates", "Modelos"), + ("app.templates", "Render Settings...", "Configurações de renderização..."), + ("app.templates", "Output Module...", "Módulo de saída..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Atalhos de teclado"), + ("", "Preferences", "Preferências"), + ("", "Composition", "Composição"), + ("comp.new", "New Composition...", "Nova composição..."), + ("comp.settings", "Composition Settings...", "Configurações da composição..."), + ("comp.setPosterTime", "Set Poster Time", "Definir tempo do pôster"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Ajustar composição à área de trabalho"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Recortar composição para a região de interesse"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Recortar composição aos limites das camadas selecionada(s)"), + ("render.addOutputModule", "Add Output Module", "Adicionar módulo de saída"), + ("", "Preview", "Pré-visualização"), + ("playback.toggle", "Play Current Preview", "Reproduzir pré-visualização atual"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Armazenar quadros quando ocioso"), + ("playback.audio", "Audio", "Áudio"), + ("", "Save Frame As", "Salvar quadro como"), + ("comp.saveFrameAs", "File...", "Arquivo..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Camadas do Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Pré-renderização..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Salvar pré-visualização atual..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir em Gráficos essenciais"), + ("", "Responsive Design — Time", "Design responsivo — Tempo"), + ("comp.responsiveTime", "Create Intro", "Criar introdução"), + ("comp.responsiveTime", "Create Outro", "Criar encerramento"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Criar região protegida a partir da área de trabalho"), + ("comp.flowchart", "Composition Flowchart", "Fluxograma da composição"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Minifluxograma da composição"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Criar ambiente VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extrair cubemapa..."), + ("", "Layer", "Camada"), + ("layer.newText", "Text", "Texto"), + ("layer.newSolid", "Solid...", "Sólido..."), + ("layer.newLight", "Light...", "Luz..."), + ("layer.newCamera", "Camera...", "Câmera..."), + ("layer.newNull", "Null Object", "Objeto nulo"), + ("layer.newShape", "Shape Layer", "Camada de forma"), + ("layer.newAdjustment", "Adjustment Layer", "Camada de ajuste"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Camada de preenchimento sensível ao conteúdo..."), + ("layer.new3dPrimitive", "Cube", "Cubo"), + ("layer.new3dPrimitive", "Sphere", "Esfera"), + ("layer.new3dPrimitive", "Plane", "Plano"), + ("layer.new3dPrimitive", "Torus", "Toro"), + ("layer.new3dPrimitive", "Cone", "Cone"), + ("layer.new3dPrimitive", "Cylinder", "Cilindro"), + ("layer.settings", "Layer Settings...", "Configurações da camada..."), + ("layer.openLayer", "Open Layer", "Abrir camada"), + ("layer.openSource", "Open Layer Source", "Abrir a fonte da camada"), + ("layer.revealInFinder", "Reveal in Finder", "Mostrar no Finder"), + ("", "Mask", "Máscara"), + ("layer.addMask", "New Mask", "Nova máscara"), + ("layer.mask.shape", "Mask Shape...", "Forma da máscara..."), + ("layer.mask.set", "Mask Feather...", "Pena da máscara..."), + ("layer.mask.set", "Mask Opacity...", "Opacidade da máscara..."), + ("layer.mask.set", "Mask Expansion...", "Expansão da máscara..."), + ("layer.mask.reset", "Reset Mask", "Redefinir máscara"), + ("layer.mask.remove", "Remove Mask", "Remover máscara"), + ("layer.mask.removeAll", "Remove All Masks", "Remover todas as máscaras"), + ("track.mask", "Track Mask", "Rastrear máscara"), + ("", "Mode", "Modo"), + ("layer.mask.mode", "None", "Nenhum"), + ("layer.mask.mode", "Add", "Adicionar"), + ("layer.mask.mode", "Subtract", "Subtrair"), + ("layer.mask.mode", "Intersect", "Interseção"), + ("layer.mask.mode", "Lighten", "Clarear"), + ("layer.mask.mode", "Darken", "Escurecer"), + ("layer.mask.mode", "Difference", "Diferença"), + ("layer.mask.invert", "Inverted", "Invertida"), + ("layer.mask.lock", "Locked", "Bloqueada"), + ("", "Motion Blur", "Borrão de movimento"), + ("layer.mask.motionBlur", "Same As Layer", "Igual à camada"), + ("layer.mask.motionBlur", "On", "Ativado"), + ("layer.mask.motionBlur", "Off", "Desativado"), + ("", "Feather Falloff", "Decaimento da pena"), + ("layer.mask.featherFalloff", "Smooth", "Suavizar"), + ("layer.mask.featherFalloff", "Linear", "Linear"), + ("layer.mask.unlockAll", "Unlock All Masks", "Desbloquear todas as máscaras"), + ("layer.mask.lockOthers", "Lock Other Masks", "Bloquear outras máscaras"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"), + ("", "Mask and Shape Path", "Caminho da máscara e da forma"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Fechado"), + ("path.convertToBezier", "Convert To Bezier Path", "Converter em caminho Bézier"), + ("path.setFirstVertex", "Set First Vertex", "Definir primeiro vértice"), + ("path.freeTransform", "Free Transform Points", "Pontos de transformação livre"), + ("", "Quality", "Qualidade"), + ("layer.quality", "Best", "Melhor"), + ("layer.quality", "Draft", "Rascunho"), + ("layer.quality", "Wireframe", "Wireframe"), + ("layer.sampling", "Bilinear", "Bilinear"), + ("layer.sampling", "Bicubic", "Bicúbico"), + ("", "Switches", "Interruptores"), + ("layer.hideOtherVideo", "Hide Other Video", "Ocultar os outros vídeos"), + ("layer.showAllVideo", "Show All Video", "Mostrar todos os vídeos"), + ("layer.unlockAll", "Unlock All Layers", "Desbloquear todas as camadas"), + ("layer.expressions", "Enable Expressions", "Ativar expressões"), + ("layer.expressions", "Disable Expressions", "Desativar expressões"), + ("layer.setSwitch", "Shy", "Escondida"), + ("layer.setSwitch", "Lock", "Bloquear"), + ("layer.setSwitch", "Audio", "Áudio"), + ("layer.setSwitch", "Video", "Vídeo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efeito"), + ("layer.setSwitch", "Collapse", "Colapsar"), + ("layer.setSwitch", "Motion Blur", "Borrão de movimento"), + ("layer.setSwitch", "Adjustment Layer", "Camada de ajuste"), + ("", "Transform", "Transformar"), + ("layer.transform", "Reset", "Redefinir"), + ("layer.setTransform", "Anchor Point...", "Ponto de ancoragem..."), + ("layer.setTransform", "Position...", "Posição..."), + ("layer.setTransform", "Scale...", "Escala..."), + ("layer.setTransform", "Orientation...", "Orientação..."), + ("layer.setTransform", "Rotation...", "Rotação..."), + ("layer.setTransform", "Opacity...", "Opacidade..."), + ("layer.transform", "Flip Horizontal", "Espelhar horizontalmente"), + ("layer.transform", "Flip Vertical", "Espelhar verticalmente"), + ("layer.transform", "Center In View", "Centralizar na visualização"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Centralizar ponto de ancoragem no conteúdo da camada"), + ("layer.transform", "Fit to Comp", "Ajustar à composição"), + ("layer.transform", "Fit to Comp Width", "Ajustar à largura da composição"), + ("layer.transform", "Fit to Comp Height", "Ajustar à altura da composição"), + ("layer.autoOrient", "Auto-Orient...", "Auto-orientar..."), + ("", "Time", "Tempo"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Ativar remapeamento de tempo"), + ("layer.timeReverse", "Time-Reverse Layer", "Inverter tempo da camada"), + ("layer.timeStretch", "Time Stretch...", "Redefinir duração..."), + ("layer.freezeFrame", "Freeze Frame", "Congelar quadro"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Congelar no último quadro"), + ("layer.alignVideoToData", "Align Video to Data", "Alinhar vídeo aos dados"), + ("", "Frame Blending", "Blenda de quadros"), + ("layer.frameBlending", "Off", "Desativado"), + ("layer.frameBlending", "Frame Mix", "Mistura de quadros"), + ("layer.frameBlending", "Pixel Motion", "Movimento de pixels"), + ("layer.setSwitch", "3D Layer", "Camada 3D"), + ("layer.setSwitch", "Guide Layer", "Camada de guia"), + ("layer.environment", "Environment Layer", "Camada de ambiente"), + ("", "Markers", "Marcadores"), + ("layer.addMarker", "Add Marker", "Adicionar marcador"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Atualizar marcadores da origem"), + ("layer.markersLock", "Lock Markers", "Bloquear marcadores"), + ("layer.deleteAllMarkers", "Delete All Markers", "Excluir todos os marcadores"), + ("layer.setSwitch", "Preserve Transparency", "Preservar transparência"), + ("", "Blending Mode", "Modo de mesclagem"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Dissolver"), + ("layer.setBlendMode", "Dancing Dissolve", "Dissolver dinâmico"), + ("layer.setBlendMode", "Darken", "Escurecer"), + ("layer.setBlendMode", "Multiply", "Multiplicar"), + ("layer.setBlendMode", "Color Burn", "Superexposição de cores"), + ("layer.setBlendMode", "Classic Color Burn", "Superexposição de cores clássica"), + ("layer.setBlendMode", "Linear Burn", "Superexposição linear"), + ("layer.setBlendMode", "Darker Color", "Cor mais escura"), + ("layer.setBlendMode", "Add", "Adição"), + ("layer.setBlendMode", "Lighten", "Clarear"), + ("layer.setBlendMode", "Screen", "Divisão"), + ("layer.setBlendMode", "Color Dodge", "Subexposição de cores"), + ("layer.setBlendMode", "Classic Color Dodge", "Subexposição de cores clássica"), + ("layer.setBlendMode", "Linear Dodge", "Subexposição linear"), + ("layer.setBlendMode", "Lighter Color", "Cor mais clara"), + ("layer.setBlendMode", "Overlay", "Sobrepor"), + ("layer.setBlendMode", "Soft Light", "Luz suave"), + ("layer.setBlendMode", "Hard Light", "Luz forte"), + ("layer.setBlendMode", "Linear Light", "Luz linear"), + ("layer.setBlendMode", "Vivid Light", "Luz viva"), + ("layer.setBlendMode", "Pin Light", "Luz pontual"), + ("layer.setBlendMode", "Hard Mix", "Mistura dura"), + ("layer.setBlendMode", "Difference", "Diferença"), + ("layer.setBlendMode", "Classic Difference", "Diferença clássica"), + ("layer.setBlendMode", "Exclusion", "Exclusão"), + ("layer.setBlendMode", "Subtract", "Subtrair"), + ("layer.setBlendMode", "Divide", "Dividir"), + ("layer.setBlendMode", "Hue", "Matiz"), + ("layer.setBlendMode", "Saturation", "Saturação"), + ("layer.setBlendMode", "Color", "Cor"), + ("layer.setBlendMode", "Luminosity", "Luminosidade"), + ("layer.setBlendMode", "Stencil Alpha", "Estêncil alfa"), + ("layer.setBlendMode", "Stencil Luma", "Estêncil luminância"), + ("layer.setBlendMode", "Silhouette Alpha", "Silhueta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Silhueta luminância"), + ("layer.setBlendMode", "Alpha Add", "Adição alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Pré-multiplicação luminescente"), + ("layer.setBlendMode", "Next Blending Mode", "Modo de mesclagem seguinte"), + ("layer.setBlendMode", "Previous Blending Mode", "Modo de mesclagem anterior"), + ("", "Track Matte", "Track matte"), + ("layer.trackMatte", "No Track Matte", "Sem track matte"), + ("layer.trackMatte", "Alpha Matte", "Matte alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Matte alfa invertida"), + ("layer.trackMatte", "Luma Matte", "Matte de luminância"), + ("layer.trackMatte", "Luma Inverted Matte", "Matte de luminância invertida"), + ("layer.trackMatte", "Matte with Layer Above", "Matte com a camada acima"), + ("layer.trackMatte", "Matte with Layer Below", "Matte com a camada abaixo"), + ("", "Layer Styles", "Estilos de camada"), + ("layer.style.options", "Layer Style Options...", "Opções de estilo de camada..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Converter em estilos editáveis"), + ("layer.style.showAll", "Show All", "Mostrar todos"), + ("layer.style.removeAll", "Remove All", "Remover todos"), + ("layer.style.dropShadow", "Drop Shadow", "Sombra projetada"), + ("layer.style.innerShadow", "Inner Shadow", "Sombra interna"), + ("layer.style.outerGlow", "Outer Glow", "Brilho externo"), + ("layer.style.innerGlow", "Inner Glow", "Brilho interno"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Chanfro e relevo"), + ("layer.style.satin", "Satin", "Satin"), + ("layer.style.colorOverlay", "Color Overlay", "Sobreposição de cores"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Sobreposição de gradiente"), + ("layer.style.stroke", "Stroke", "Traçado"), + ("path.groupShapes", "Group Shapes", "Agrupar formas"), + ("path.ungroupShapes", "Ungroup Shapes", "Desagrupar formas"), + ("", "Arrange", "Organizar"), + ("layer.arrange", "Bring Layer to Front", "Trazer camada para o topo"), + ("layer.arrange", "Bring Layer Forward", "Trazer camada para a frente"), + ("layer.arrange", "Send Layer Backward", "Enviar camada para trás"), + ("layer.arrange", "Send Layer to Back", "Enviar camada para o fundo"), + ("", "Reveal", "Revelar"), + ("layer.revealSource", "Reveal Layer Source in Project", "Revelar origem da camada no projeto"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Revelar camada no fluxograma do projeto"), + ("comp.revealInProject", "Reveal Composition in Project", "Revelar composição no projeto"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Revelar erros de expressão"), + ("", "Create", "Criar"), + ("layer.create", "Convert to Editable Text", "Converter em texto editável"), + ("layer.create", "Create Shapes from Text", "Criar formas a partir do texto"), + ("layer.create", "Create Masks from Text", "Criar máscaras a partir do texto"), + ("layer.create", "Create Shapes from Vector Layer", "Criar formas a partir da camada vetorial"), + ("layer.create", "Create Keyframes from Data", "Criar quadros-chave a partir de dados"), + ("layer.create", "Null Controllers for Positional Points", "Controles null para pontos posicionais"), + ("layer.create", "Null Controllers for Path Points", "Controles null para pontos do caminho"), + ("layer.create", "Nulls Following Path Points", "Nulls seguindo pontos do caminho"), + ("layer.create", "Null Tracing Along Path", "Null traçando ao longo do caminho"), + ("layer.create", "Create 3D Layer Instance", "Criar instância de camada 3D"), + ("", "Camera", "Câmera"), + ("camera.fromView", "Create Camera from 3D View", "Criar câmera a partir da visualização 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Criar estrutura 3D estéreo"), + ("camera.orbitNull", "Create Orbit Null", "Criar null de órbita"), + ("camera.fromModel", "Create Cameras from 3D Model", "Criar câmeras a partir de modelo 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Vincular distância de foco ao ponto de interesse"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Vincular distância de foco à camada"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Definir distância de foco para a camada"), + ("layer.cameraSettings", "Camera Settings...", "Configurações da câmera..."), + ("view.reset3DView", "Reset 3D View", "Redefinir visualização 3D"), + ("", "Light", "Luz"), + ("light.fromModel", "Create Lights from 3D Model", "Criar luzes a partir de modelo 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Controlar luz com a câmera"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Criar camada de fundo de luz ambiente"), + ("", "Material", "Material"), + ("material.revealSource", "Reveal Material Source in Project", "Revelar origem do material no projeto"), + ("material.reset", "Reset Material", "Redefinir material"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Duplicar e atribuir material"), + ("layer.autoTrace", "Auto-trace...", "Traçado automático..."), + ("layer.precompose", "Pre-compose...", "Pré-compor..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Detecção de edição de cena..."), + ("", "Effect", "Efeito"), + ("window.panel", "Effect Controls", "Controles de efeito"), + ("effect.applyLast", "Last Effect", "Último efeito"), + ("effect.removeAll", "Remove All", "Remover todos"), + ("effect.manage", "Manage Effects...", "Gerenciar efeitos..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Carregar plug-in de efeito..."), + ("", "Animation", "Animação"), + ("anim.savePreset", "Save Animation Preset...", "Salvar predefinição de animação..."), + ("anim.applyPreset", "Apply Animation Preset...", "Aplicar predefinição de animação..."), + ("", "Recent Animation Presets", "Predefinições de animação recentes"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Limpar predefinições recentes"), + ("anim.browsePresets", "Browse Presets...", "Procurar predefinições..."), + ("", "Text Animation Presets", "Predefinições de animação de texto"), + ("layer.applyTextPreset", "Typewriter", "Máquina de escrever"), + ("layer.applyTextPreset", "Fade Up Characters", "Aparecer caractere a caractere"), + ("layer.applyTextPreset", "Bounce In Words", "Entrar quicando nas palavras"), + ("layer.applyTextPreset", "Tracking In", "Entrar com espaçamento"), + ("layer.applyTextPreset", "Scramble", "Embaralhar"), + ("layer.applyTextPreset", "Blur In", "Entrar desfocado"), + ("layer.applyTextPreset", "Jitter", "Tremor"), + ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), + ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), + ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), + ("", "Keyframe Assistant", "Assistente de quadro-chave"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Converter áudio em quadros-chave"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Converter expressão em quadros-chave"), + ("keys.easyEase", "Easy Ease", "Easy Ease"), + ("keys.easyEaseIn", "Easy Ease In", "Easy Ease In"), + ("keys.easyEaseOut", "Easy Ease Out", "Easy Ease Out"), + ("keys.exponentialScale", "Exponential Scale", "Escala exponencial"), + ("keys.rpfCameraImport", "RPF Camera Import", "Importação de câmera RPF"), + ("layer.sequence", "Sequence Layers...", "Sequenciar camadas..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Inverter ordem dos quadros-chave"), + ("", "Animate Text", "Animar texto"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Ativar 3D por caractere"), + ("layer.addTextAnimator", "Anchor Point", "Ponto de ancoragem"), + ("layer.addTextAnimator", "Position", "Posição"), + ("layer.addTextAnimator", "Scale", "Escala"), + ("layer.addTextAnimator", "Skew", "Distorção"), + ("layer.addTextAnimator", "Rotation", "Rotação"), + ("layer.addTextAnimator", "Opacity", "Opacidade"), + ("layer.addTextAnimator", "All Transform Properties", "Todas as propriedades de transformação"), + ("", "Fill Color", "Cor de preenchimento"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Matiz"), + ("layer.addTextAnimator", "Saturation", "Saturação"), + ("layer.addTextAnimator", "Brightness", "Brilho"), + ("", "Stroke Color", "Cor do traçado"), + ("layer.addTextAnimator", "Stroke Width", "Largura do traçado"), + ("layer.addTextAnimator", "Tracking", "Tracking"), + ("layer.addTextAnimator", "Line Anchor", "Âncora da linha"), + ("layer.addTextAnimator", "Line Spacing", "Espaçamento entre linhas"), + ("layer.addTextAnimator", "Character Offset", "Deslocamento de caractere"), + ("layer.addTextAnimator", "Character Value", "Valor do caractere"), + ("layer.addTextAnimator", "Blur", "Desfoque"), + ("text.animatorFontAxes", "Variable Font Axes", "Eixos de fonte variável"), + ("", "Add Text Selector", "Adicionar seletor de texto"), + ("text.addSelector", "Range", "Intervalo"), + ("text.addSelector", "Wiggly", "Oscilante"), + ("text.addSelector", "Expression", "Expressão"), + ("text.removeAllAnimators", "Remove All Text Animators", "Remover todos os animadores de texto"), + ("prop.setExpression", "Add Expression", "Adicionar expressão"), + ("essential.addProperty", "Add Property to Essential Graphics", "Adicionar propriedade ao Gráficos essenciais"), + ("prop.separateDimensions", "Separate Dimensions", "Dimensões separadas"), + ("track.camera", "Track Camera", "Rastrear câmera"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "Rastrear movimento"), + ("track.stabilize", "Stabilize Motion", "Estabilizar movimento"), + ("track.property", "Track this Property", "Rastrear esta propriedade"), + ("anim.reveal", "Reveal Properties with Keyframes", "Revelar propriedades com quadros-chave"), + ("anim.reveal", "Reveal Properties with Animation", "Revelar propriedades com animação"), + ("anim.reveal", "Reveal All Modified Properties", "Revelar todas as propriedades modificadas"), + ("", "View", "Exibir"), + ("view.newViewer", "New Viewer", "Nova visualização"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Dividir com nova visualização bloqueada"), + ("view.zoomIn", "Zoom In", "Aproximar"), + ("view.zoomOut", "Zoom Out", "Afastar"), + ("", "Resolution", "Resolução"), + ("view.res.full", "Full", "Total"), + ("view.res.half", "Half", "Meio"), + ("view.res.third", "Third", "Um terço"), + ("view.res.quarter", "Quarter", "Um quarto"), + ("view.res.custom", "Custom...", "Personalizado..."), + ("view.displayColorManagement", "Use Display Color Management", "Usar gerenciamento de cores do monitor"), + ("", "Simulate Output", "Simular saída"), + ("view.simulateOutput", "No Output Simulation", "Sem simulação de saída"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "RGB legado do Macintosh (Gamma 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "RGB padrão da internet (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Linear (Gamma 1.0)"), + ("view.customRgb", "My Custom RGB...", "Meu RGB personalizado..."), + ("view.simulateOutput", "Custom...", "Personalizado..."), + ("view.rulers", "Show Rulers", "Mostrar réguas"), + ("", "Panel Background Color", "Cor de fundo do painel"), + ("view.panelBackground", "Black", "Preto"), + ("view.panelBackground", "Dark Gray", "Cinza escuro"), + ("view.panelBackground", "Medium Gray (Default)", "Cinza médio (padrão)"), + ("view.panelBackground", "Light Gray", "Cinza claro"), + ("view.panelBackground", "White", "Branco"), + ("view.panelBackground", "Custom", "Personalizado"), + ("view.panelBackground", "Select Custom Background Color...", "Selecionar cor de fundo personalizada..."), + ("view.guides", "Show Guides", "Mostrar guias"), + ("view.snapToGuides", "Snap to Guides", "Encaixar nas guias"), + ("view.lockGuides", "Lock Guides", "Bloquear guias"), + ("view.addGuide", "Add Guide...", "Adicionar guia..."), + ("view.clearGuides", "Clear Guides", "Limpar guias"), + ("view.importGuides", "Import Guides...", "Importar guias..."), + ("view.exportGuides", "Export Guides...", "Exportar guias..."), + ("view.grid", "Show Grid", "Mostrar grade"), + ("view.snapToGrid", "Snap to Grid", "Encaixar na grade"), + ("view.snapping", "Snapping", "Encaixe"), + ("view.options", "View Options...", "Opções de visualização..."), + ("view.layerControls", "Show Layer Controls", "Mostrar controles da camada"), + ("", "Switch View Layout", "Alternar layout de visualização"), + ("view.layout", "1 View", "1 visualização"), + ("view.layout", "2 Views", "2 visualizações"), + ("view.layout", "4 Views", "4 visualizações"), + ("view.shareViewOptions", "Share View Options", "Compartilhar opções de visualização"), + ("", "Switch 3D View", "Alternar visualização 3D"), + ("view.3d.activeCamera", "Active Camera", "Câmera ativa"), + ("view.3d.default", "Default", "Padrão"), + ("view.3d.front", "Front", "Frente"), + ("view.3d.left", "Left", "Esquerda"), + ("view.3d.top", "Top", "Topo"), + ("view.3d.back", "Back", "Atrás"), + ("view.3d.right", "Right", "Direita"), + ("view.3d.bottom", "Bottom", "Base"), + ("view.3d.custom1", "Custom View 1", "Visualização personalizada 1"), + ("view.3d.custom2", "Custom View 2", "Visualização personalizada 2"), + ("view.3d.custom3", "Custom View 3", "Visualização personalizada 3"), + ("", "Assign Shortcut to 3D View", "Atribuir atalho à visualização 3D"), + ("view.3d.last", "Switch to Last 3D View", "Alternar para a última visualização 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Olhar para as camadas selecionadas"), + ("view.lookAtAll", "Look at All Layers", "Olhar para todas as camadas"), + ("time.set", "Go to Time...", "Ir para o tempo..."), + ("view.fullScreen", "Enter Full Screen", "Entrar em tela cheia"), + ("", "Window", "Janela"), + ("", "Workspace", "Área de trabalho"), + ("window.workspace", "Default", "Inicial"), + ("window.workspace", "Review", "Revisão"), + ("window.workspace", "Learn", "Aprender"), + ("window.workspace", "Small Screen", "Tela pequena"), + ("window.workspace", "Standard", "Padrão"), + ("window.workspace", "All Panels", "Todos os painéis"), + ("window.workspace", "Animation", "Animação"), + ("window.workspace", "Color", "Cores"), + ("window.workspace", "Effects", "Efeitos"), + ("window.workspace", "Essential Graphics", "Gráficos essenciais"), + ("window.workspace", "Minimal", "Mínimo"), + ("window.workspace", "Motion Tracking", "Rastreamento de movimento"), + ("window.workspace", "Paint", "Pintura"), + ("window.workspace", "Text", "Texto"), + ("window.workspace", "Undocked Panels", "Painéis desacoplados"), + ("window.resetWorkspace", "Reset to Saved Layout", "Redefinir para o layout salvo"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Salvar alterações nesta área de trabalho"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Salvar como nova área de trabalho..."), + ("window.editWorkspaces", "Edit Workspaces...", "Editar áreas de trabalho..."), + ("", "Assign Shortcut to Workspace", "Atribuir atalho à área de trabalho"), + ("window.panel", "Align", "Alinhar"), + ("window.panel", "Audio", "Áudio"), + ("window.panel", "Brushes", "Pincéis"), + ("window.panel", "Character", "Caractere"), + ("window.panel", "Content-Aware Fill", "Preenchimento sensível ao conteúdo"), + ("window.panel", "Effects & Presets", "Efeitos e predefinições"), + ("window.panel", "Essential Graphics", "Gráficos essenciais"), + ("window.panel", "Info", "Informações"), + ("help.inAppTutorials", "Learn", "Aprender"), + ("window.panel", "Lumetri Scopes", "Escopos Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolação de máscara"), + ("window.panel", "Media Browser", "Navegador de mídia"), + ("window.panel", "Metadata", "Metadados"), + ("window.panel", "Motion Sketch", "Esboço de movimento"), + ("window.panel", "Paint", "Pintura"), + ("window.panel", "Paragraph", "Parágrafo"), + ("window.panel", "Preview", "Pré-visualização"), + ("window.panel", "Progress", "Progresso"), + ("window.panel", "Properties", "Propriedades"), + ("window.panel", "Script Console", "Console de script"), + ("window.panel", "Smoother", "Suavizador"), + ("window.panel", "Tools", "Ferramentas"), + ("window.panel", "Tracker", "Rastreador"), + ("window.panel", "Wiggler", "Oscilador"), + ("window.panel", "Composition", "Composição"), + ("window.panel", "Flowchart", "Fluxograma"), + ("window.panel", "Footage", "Mídia"), + ("window.panel", "Layer", "Camada"), + ("window.panel", "Project", "Projeto"), + ("window.panel", "Render Queue", "Fila de renderização"), + ("window.panel", "Timeline", "Linha do tempo"), + ("window.panel", "Create Nulls From Paths", "Criar nulls a partir de caminhos"), + ("window.panel", "VR Comp Editor", "Editor de composição VR"), + ("", "Help", "Ajuda"), + ("help.docs", "EffectCraft Help...", "Ajuda do EffectCraft..."), + ("help.docs", "Scripting Help...", "Ajuda de scripting..."), + ("help.docs", "Expression Reference...", "Referência de expressões..."), + ("help.docs", "Effect Reference...", "Referência de efeitos..."), + ("anim.browsePresets", "Animation Presets...", "Predefinições de animação..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Atalhos de teclado..."), + ("help.inAppTutorials", "In-App Tutorials...", "Tutoriais no aplicativo..."), + ("help.onlineTutorials", "Online Tutorials...", "Tutoriais online..."), + ("help.systemReport", "System Compatibility Report...", "Relatório de compatibilidade do sistema..."), + ("help.enableLogging", "Enable Logging", "Ativar registro em log"), + ("help.revealLogFile", "Reveal Logging File", "Revelar arquivo de log"), + ("help.discord", "Join the ArtCraft Discord...", "Entrar no Discord do ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Enviar comentários..."), + ("help.website", "ArtCraft Website", "Site do ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Página inicial do EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft no GitHub"), + ("file.openDemoProject", "Open Demo Project", "Abrir projeto de demonstração"), +]; + #[cfg(test)] mod tests { use super::*; @@ -2696,11 +3997,15 @@ mod tests { let mut chinese_keys = BTreeSet::new(); let mut traditional_keys = BTreeSet::new(); let mut ukrainian_keys = BTreeSet::new(); + let mut spanish_keys = BTreeSet::new(); + let mut portuguese_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), (TRADITIONAL_CHINESE, &mut traditional_keys), (UKRAINIAN, &mut ukrainian_keys), + (SPANISH, &mut spanish_keys), + (PORTUGUESE, &mut portuguese_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -2724,9 +4029,35 @@ mod tests { // Every translated language covers exactly the same entries. assert_eq!(japanese_keys, chinese_keys, "the languages must translate the same entries"); assert_eq!(chinese_keys, traditional_keys, "the languages must translate the same entries"); + assert_eq!(traditional_keys, spanish_keys, "the languages must translate the same entries"); + assert_eq!(spanish_keys, portuguese_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, ukrainian_keys, "the languages must translate the same entries"); } + /// Exercise each engine spelling on every host: a macOS-only run must still catch a + /// Windows/Linux catalog miss, while filenames and unrelated commands stay untouched. + #[test] + fn ukrainian_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "uk"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Показати у Finder"), + ("Reveal in Explorer", "Показати у Провіднику"), + ("Reveal in File Manager", "Показати у файловому менеджері"), + ] { + let e = MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None }; + assert_eq!(label(&app, command, source), translated); + assert_eq!(entry(&app, &e, source.into()), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4"); + } + app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); + assert_eq!(label(&app, "file.revealInFinder", "Reveal in Explorer"), "Reveal in Explorer"); + assert_eq!(label(&app, "layer.revealInFinder", "Reveal in File Manager"), "Reveal in File Manager"); + } + #[test] fn language_changes_native_labels_without_changing_commands_or_parameters() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); @@ -2776,6 +4107,12 @@ mod tests { (Some("uk"), "uk"), (Some("uk-UA"), "uk"), (Some("UK_ua.UTF-8"), "uk"), + (Some("es-ES"), "es"), + (Some("es"), "es"), + (Some("es_MX.UTF-8"), "es"), + (Some("es-419"), "es"), + (Some("pt-BR"), "pt-br"), + (Some("pt_PT.UTF-8"), "pt-br"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -2788,6 +4125,7 @@ mod tests { assert_eq!(japanese(&app), system_language() == "ja"); assert_eq!(simplified(&app), system_language() == "zh-hans"); assert_eq!(traditional(&app), system_language() == "zh-hant"); + assert_eq!(spanish(&app), system_language() == "es"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -2798,11 +4136,16 @@ mod tests { assert!(traditional(&app)); assert!(!simplified(&app)); assert!(!japanese(&app)); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"es"})).unwrap(); + assert!(spanish(&app)); + assert!(!traditional(&app)); + assert!(!simplified(&app)); + assert!(!japanese(&app)); } #[test] - fn every_chinese_and_ukrainian_entry_keeps_the_english_shape() { - for (code, catalog) in [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN)] { + fn every_translated_entry_keeps_the_english_shape() { + for (code, catalog) in [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE)] { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); @@ -2951,6 +4294,84 @@ mod tests { assert_eq!(code("unknown"), "en"); } + #[test] + fn es_translates_labels_computed_titles_and_keeps_names_verbatim() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"es"})).unwrap(); + assert_eq!(language(&app), "es"); + assert!(spanish(&app)); + assert!(!traditional(&app)); + assert!(!simplified(&app)); + assert!(!japanese(&app)); + // Fixed menu rows come from the catalog. + assert_eq!(label(&app, "", "Composition"), "Composición"); + assert_eq!(label(&app, "", "Effect"), "Efecto"); + assert_eq!(label(&app, "comp.new", "New Composition..."), "Nueva composición..."); + assert_eq!(label(&app, "layer.newText", "Text"), "Texto"); + assert_eq!(label(&app, "layer.setBlendMode", "Color Burn"), "Subexposición de color"); + // Untranslated keys and dynamic labels stay verbatim. + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + assert_eq!( + entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + "No se puede deshacer" + ); + assert_eq!( + entry( + &app, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + "Layer: File".into() + ), + "Capa: File" + ); + // The submenu titles that name the current workspace / view. + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), + "Asignar método abreviado al espacio de trabajo “Default”" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Asignar método abreviado a la vista 3D “Top”"); + // The menu tree carries the same commands and shortcuts with the Spanish labels. + let es = crate::native_menu::build(&app); + assert!(es.items().iter().any(|i| i.command == "comp.new" && i.label == "Nueva composición...")); + assert!(es.items().iter().any(|i| i.command == "layer.newText" && i.label == "Texto")); + assert!(es.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "Suavizado")); + // A dynamic filename is never translated. + app.session.prefs.push_recent("/tmp/File.ecproj"); + assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + } + + #[test] + fn pt_br_translates_labels_computed_titles_and_keeps_names_verbatim() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"pt-br"})).unwrap(); + assert_eq!(language(&app), "pt-br"); + assert!(!japanese(&app)); + // Fixed menu rows come from the catalog. + assert_eq!(label(&app, "", "Composition"), "Composição"); + assert_eq!(label(&app, "", "Effect"), "Efeito"); + assert_eq!(label(&app, "comp.new", "New Composition..."), "Nova composição..."); + assert_eq!(label(&app, "layer.newText", "Text"), "Texto"); + // Untranslated keys and dynamic labels stay verbatim. + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + assert_eq!( + entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + "Não há nada para desfazer" + ); + assert_eq!( + entry( + &app, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + "Layer: File".into() + ), + "Camada: File" + ); + // The submenu titles that name the current workspace / view. + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), + "Atribuir atalho à área de trabalho “Default”" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Atribuir atalho à visualização 3D “Top”"); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 4afb43b1..fdcaa27e 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -40,11 +40,15 @@ type Index = HashMap<&'static str, &'static str>; /// The catalog for `language`, indexed by English source (built on first use: panels translate /// hundreds of strings a frame); `None` for English and any language that has no rows yet. fn catalog(language: &str) -> Option<&'static Index> { + static JA: OnceLock = OnceLock::new(); static HANS: OnceLock = OnceLock::new(); static HANT: OnceLock = OnceLock::new(); + static UK: OnceLock = OnceLock::new(); let (index, rows) = match language { + "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), "zh-hant" => (&HANT, TRADITIONAL_CHINESE), + "uk" => (&UK, UKRAINIAN), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -75,6 +79,434 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { out } +/// Japanese: the same rows, in the same order, as the Chinese catalogs below (the tests keep the two in step). +const JAPANESE: &[(&str, &str)] = &[ + (" OK ", " OK "), + (" Delete ", " 削除 "), + (" Open Auto-Save ", " 自動保存を開く "), + ("(missing)", "(見つかりません)"), + ("360 View Orientation", "360度ビューの方向"), + ("3:2 Pulldown: {}", "3:2プルダウン:{}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "モーショングラフィックスとビジュアルエフェクト向けの、クリーンルーム方式で開発されたオープンソースのコンポジットソフトウェアです。macOS、Windows、Linuxでネイティブに動作し、ブラウザーでも使えます。ArtCraftクリエイティブアプリファミリーの一員です。", + ), + ("About EffectCraft", "EffectCraftについて"), + ("About...", "情報..."), + ("Active Camera", "アクティブカメラ"), + ("Adapt Feature On Every Frame", "フレームごとに特徴を更新"), + ("Adaptive Sample Limit:", "適応サンプル数の上限:"), + ("Add Animator", "アニメーターを追加"), + ("Add Comment", "コメントを追加"), + ("Add Formatting", "書式を追加"), + ("Add Group", "グループを追加"), + ("Add Mirror", "ミラーを追加"), + ("Add Selected As", "選択項目を次の形式で追加"), + ("Add a comment", "コメントを追加"), + ("Add a shape, fill, stroke or path operation", "シェイプ、塗り、線、またはパス操作を追加"), + ("Add to Composition", "コンポジションに追加"), + ("Add to Favorites", "お気に入りに追加"), + ("Add:", "追加:"), + ("Adjust Exposure (stops)", "露出を調整(段単位)"), + ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced 3D", "アドバンスド3D"), + ("Align Layers to:", "レイヤーの整列基準:"), + ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), + ("Anchor:", "アンカー:"), + ("Aperture", "絞り"), + ("Apply Dimensions:", "適用する次元:"), + ("Apply Motion To:", "モーションの適用先:"), + ("Arrange", "重ね順"), + ("Assign", "割り当て"), + ("Auto", "自動"), + ("Auto-saves", "自動保存"), + ("Back", "戻る"), + ("Background Color:", "背景色:"), + ("Blend Mode:", "描画モード:"), + ("Blending Mode", "描画モード"), + ("Blur", "ブラー"), + ("Blur Level", "ブラーレベル"), + ("Browser Storage", "ブラウザーストレージ"), + ("Browse…", "参照…"), + ("Brush Dynamics", "ブラシのダイナミクス"), + ("Caching frames…", "フレームをキャッシュ中…"), + ("Cancel", "キャンセル"), + ("Casts Shadows", "シャドウを落とす"), + ("Change Renderer…", "レンダラーを変更…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "ショートカットを変更するとカスタムプリセットが作成されます。初期設定のプリセットはそのまま残ります。", + ), + ("Channel", "チャンネル"), + ("Channel:", "チャンネル:"), + ("Chapter:", "チャプター:"), + ("Choose...", "選択..."), + ("Classic 3D", "クラシック3D"), + ("Clear", "消去"), + ("Clear list", "リストを消去"), + ("Click a key to see its commands.", "キーをクリックすると、割り当てられたコマンドが表示されます。"), + ("Click adds a point (½ height = no change)", "クリックでポイントを追加(高さの½=変化なし)"), + ("Click adds a point; drag one off to remove", "クリックでポイントを追加、外へドラッグで削除"), + ("Click to make this viewer active", "クリックしてこのビューアーをアクティブ化"), + ("Click to reveal the expression in the timeline", "クリックしてタイムラインでエクスプレッションを表示"), + ("Click to show the active composition here", "クリックしてアクティブなコンポジションをここに表示"), + ("Click, then click in the Composition panel", "クリックしてから、コンポジションパネル内をクリック"), + ("Clone Options", "クローンオプション"), + ("Clone Source Overlay:", "クローンソースのオーバーレイ:"), + ("Close", "閉じる"), + ("Close Panel", "パネルを閉じる"), + ("Close tutorial", "チュートリアルを閉じる"), + ("Color", "カラー"), + ("Color Depth: {}", "色深度:{}"), + ("Color:", "カラー:"), + ("Colour map", "カラーマップ"), + ("Columns", "列"), + ("Comment:", "コメント:"), + ("Community", "コミュニティ"), + ("Composition", "コンポジション"), + ("Composition Mini-Flowchart", "コンポジションミニフローチャート"), + ("Composition Name:", "コンポジション名:"), + ("Composition Settings ▸ 3D Renderer", "コンポジション設定 ▸ 3Dレンダラー"), + ("Composition Settings…", "コンポジション設定…"), + ("Composition marker bin: drag a marker to the time ruler", "コンポジションマーカーのビン:マーカーをタイムルーラーへドラッグ"), + ("Cone Angle", "円錐の角度"), + ("Cone Feather", "円錐の境界のぼかし"), + ("Constrain Proportions", "縦横比を固定"), + ("Continuous (Lock Outgoing to Incoming)", "連続(出る速度を入る速度に固定)"), + ("Cue Point", "キューポイント"), + ("Current Render", "現在のレンダリング"), + ("Darker", "暗く"), + ("Default Output:", "既定の出力:"), + ("Default: {}", "初期設定:{}"), + ("Defaults", "初期設定"), + ("Delete", "削除"), + ("Delete Selected Points", "選択したポイントを削除"), + ("Delete selected project items", "選択したプロジェクト項目を削除"), + ("Delete this template", "このテンプレートを削除"), + ("Depth of Field", "被写界深度"), + ("Discord", "Discord"), + ("Distribute Layers:", "レイヤーの分布:"), + ("Don't Recover", "復元しない"), + ("Don't Save", "保存しない"), + ("Double-click a layer to open it in the Layer panel", "レイヤーをダブルクリックしてレイヤーパネルで開く"), + ("Double-click footage in the Project panel to open it here", "プロジェクトパネルのフッテージをダブルクリックしてここで開く"), + ("Double-click to edit the comment", "ダブルクリックしてコメントを編集"), + ("Download", "ダウンロード"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "タイムラインからここへプロパティをドラッグするか、\n「アニメーション > エッセンシャルグラフィックスにプロパティを追加」を使用してください。", + ), + ("Drag to change", "ドラッグして変更"), + ("Drag to create a guide", "ドラッグしてガイドを作成"), + ("Drag to slip the source", "ドラッグしてソースをスリップ"), + ("Drop Frame", "ドロップフレーム"), + ("Duplicate", "複製"), + ("Duration:", "デュレーション:"), + ("Edit Gradient…", "グラデーションを編集…"), + ("Edit Target: {}", "編集対象:{}"), + ("Edit in the Timeline", "タイムラインで編集"), + ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), + ("Effects & Presets options", "エフェクト&プリセットのオプション"), + ("Effects: {}", "エフェクト:{}"), + ("Empty folder", "空のフォルダー"), + ("Enable Depth of Field", "被写界深度を有効化"), + ("Enable Expression", "エクスプレッションを有効化"), + ("Enhance", "強調"), + ("Essential Properties — {}", "エッセンシャルプロパティ — {}"), + ("Event", "イベント"), + ("Export Template…", "テンプレートを書き出し…"), + ("Export...", "書き出し..."), + ("Expression Language Menu", "エクスプレッション言語メニュー"), + ("Expression pick whip: drag onto a property", "エクスプレッションのピックウィップ:プロパティへドラッグ"), + ("Expression:", "エクスプレッション:"), + ("Eyedropper: click, then click in the Composition panel", "スポイト:クリックしてから、コンポジションパネル内をクリック"), + ("Falloff", "減衰"), + ("Falloff Distance", "減衰距離"), + ("Favorites", "お気に入り"), + ("Field Render: {}", "フィールドレンダリング:{}"), + ("Figures", "数字"), + ("Fill Target", "塗りつぶし対象"), + ("Finished", "完了"), + ("Flowchart: {}", "フローチャート:{}"), + ("Focus Distance", "フォーカス距離"), + ("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"), + ("Footage: {}", "フッテージ:{}"), + ("Format: {}", "形式:{}"), + ("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"), + ("Frame Blending: {}", "フレームブレンド:{}"), + ("Frame Rate:", "フレームレート:"), + ("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"), + ("Frame Target:", "フレームターゲット:"), + ("Grab bag", "いろいろ"), + ("Grid", "グリッド"), + ("Guide Layers: {}", "ガイドレイヤー:{}"), + ("Guides", "ガイド"), + ("Height:", "高さ:"), + ("Hide This", "この列を非表示"), + ("Histogram", "ヒストグラム"), + ("Hold In Place", "固定する位置"), + ("Home", "ホーム"), + ("If Confidence is Below", "信頼度が次の値未満の場合"), + ("Import", "読み込み"), + ("Import and Add to Composition", "読み込んでコンポジションに追加"), + ("Import...", "読み込み..."), + ("Imported media", "読み込んだメディア"), + ("Influence:", "影響:"), + ("Install from File…", "ファイルからインストール…"), + ("Intensity", "強度"), + ("Inverted", "反転"), + ("Join the ArtCraft community on Discord", "DiscordでArtCraftコミュニティに参加"), + ("Key: {}", "キー:{}"), + ("LEARN", "学習"), + ("Label", "ラベル"), + ("Label Colors and Names:", "ラベルカラーと名前:"), + ("Label:", "ラベル:"), + ("Latest auto-save: {}", "最新の自動保存:{}"), + ("Layer", "レイヤー"), + ("Layer controls", "レイヤーコントロール"), + ("Layer {}", "レイヤー{}"), + ("Layer:", "レイヤー:"), + ("Layer: {}", "レイヤー:{}"), + ("Learn", "学習"), + ("Leave all attributes in \"{}\"", "すべての属性を「{}」に残す"), + ("Licence: {}", "ライセンス:{}"), + ("Ligatures", "合字"), + ("Light Type", "ライトの種類"), + ("Lighter", "明るく"), + ("Link Selected Property", "選択したプロパティをリンク"), + ("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"), + ("Lock to Zoom", "ズームにロック"), + ("Locked: {} — {}", "ロック済み:{} — {}"), + ("Log:", "ログ:"), + ("Loop (Trace Path)", "ループ(パスをトレース)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • フォント:Inter、JetBrains Mono (OFL)"), + ("Magnification ratio popup", "拡大率のポップアップ"), + ("Make Comp Size", "コンポジションサイズにする"), + ("Masks", "マスク"), + ("Maximize Panel", "パネルを最大化"), + ("Media Replacement", "メディアの置き換え"), + ("Mesh:", "メッシュ:"), + ("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"), + ("Models folder: {}", "モデルフォルダー:{}"), + ("Modifiers:", "修飾キー:"), + ("More", "その他"), + ("More ArtCraft apps", "ほかのArtCraftアプリ"), + ("More tutorials", "ほかのチュートリアル"), + ("Motion Blur", "モーションブラー"), + ("Motion Blur: {}", "モーションブラー:{}"), + ("Motion Sketch: drag to record", "モーションスケッチ:ドラッグして記録"), + ("Move Down", "下へ移動"), + ("Move Up", "上へ移動"), + ("Move to Project Root", "プロジェクトのルートへ移動"), + ("Name", "名前"), + ("Name:", "名前:"), + ("Navigation", "ナビゲーション"), + ("New", "新規"), + ("New Comp from Selection", "選択範囲から新規コンポジション"), + ("New Duration:", "新しいデュレーション:"), + ("New composition name:", "新規コンポジション名:"), + ("New from Render Queue item:", "レンダーキュー項目から新規作成:"), + ("New from Template", "テンプレートから新規作成"), + ("Next", "次へ"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "お疲れさまでした。ホーム画面の「学習」タブで別のチュートリアルを選んでください。"), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "VR環境はまだありません。次のメニューで作成してください:コンポジション ▸ VR ▸ VR環境を作成…", + ), + ("No audio layer selected", "オーディオレイヤーが選択されていません"), + ("No background jobs running", "実行中のバックグラウンドジョブはありません"), + ("No composition markers", "コンポジションマーカーはありません"), + ("No contributor data was built into this copy.", "このビルドにはコントリビューターのデータが含まれていません。"), + ("No device list from this host; the system default output is used.", "このホストからはデバイス一覧を取得できないため、システムの既定の出力を使用します。"), + ("No layer selected", "レイヤーが選択されていません"), + ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), + ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), + ("No nested compositions", "ネストされたコンポジションはありません"), + ("No shortcut", "ショートカットなし"), + ("No unlocked layer selected", "ロックされていないレイヤーが選択されていません"), + ("No video", "ビデオなし"), + ("None", "なし"), + ("Not applied: tick it in the list to add it.", "未適用:リストでチェックを入れると追加されます。"), + ("Not assigned", "未割り当て"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "注意:シャドウは「シャドウを投影」が有効なレイヤーからのみ投影されます。"), + ("Notify", "通知"), + ("OK", "OK"), + ("Offset:", "オフセット:"), + ("On ({}) stop:", "({})で停止したとき:"), + ("One-Node Camera", "1ノードカメラ"), + ("Opacity:", "不透明度:"), + ("Open Composition", "コンポジションを開く"), + ("Open Last Saved Project", "最後に保存したプロジェクトを開く"), + ("Open New Composition", "新規コンポジションを開く"), + ("Open Output", "出力を開く"), + ("Open a composition to build Essential Graphics", "エッセンシャルグラフィックスを作成するには、コンポジションを開いてください"), + ("Open a composition to see its scopes", "コンポジションを開くとスコープが表示されます"), + ("Open a composition to track motion.", "モーションをトラックするには、コンポジションを開いてください。"), + ("Opus Tuning: {}", "Opusチューニング:{}"), + ("Original Duration:", "元のデュレーション:"), + ("Output Cycle: {}", "出力サイクル:{}"), + ("Output To:", "出力先:"), + ("Parameter Name:", "パラメーター名:"), + ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), + ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), + ("Playing on Video Preview", "ビデオプレビューで再生中"), + ("Post-Render Action: {}", "レンダリング後の処理:{}"), + ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), + ("Preserve resolution when nested", "ネスト時に解像度を保持"), + ("Preset", "プリセット"), + ("Preset:", "プリセット:"), + ("Presets", "プリセット"), + ("Previous", "前へ"), + ("Primary:", "プライマリ:"), + ("Process Before Match", "マッチ前の処理"), + ("Profile: {}", "プロファイル:{}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "プロジェクトの色深度:クリックで8/16/32 bpcを切り替え、Alt+クリックでプロジェクト設定を開く", + ), + ("Project: {}", "プロジェクト:{}"), + ("Projects", "プロジェクト"), + ("Property pick whip: drag onto a property", "プロパティピックウィップ:プロパティ上にドラッグ"), + ("Protected Region", "保護領域"), + ("Proxy Use: {}", "プロキシの使用:{}"), + ("Push All to Comp", "すべてをコンポジションに反映"), + ("Radius", "半径"), + ("Rate Control: {}", "レート制御:{}"), + ("Recent", "最近使用した項目"), + ("Recording", "記録中"), + ("Remove", "削除"), + ("Rename", "名前を変更"), + ("Render: {} ms • UI {} fps{}", "レンダリング:{} ms • UI {} fps{}"), + ("Renderer:", "レンダラー:"), + ("Request Persistent Storage", "永続ストレージを要求"), + ("Reset", "リセット"), + ("Reset Labels", "ラベルをリセット"), + ("Reset Preset", "プリセットをリセット"), + ("Reset to Default", "初期設定にリセット"), + ("Reset to Saved Layout", "保存したレイアウトにリセット"), + ("Resolution/Down Sample Factor Popup", "解像度/ダウンサンプル係数のポップアップ"), + ("Restore Panel Size", "パネルのサイズを元に戻す"), + ("Reverse the order", "順序を逆にする"), + ("Revert All", "すべて元に戻す"), + ("Roman Hanging Punctuation", "欧文句読点のぶら下がり"), + ("Roving:", "ロービング:"), + ("Ruler zero point (drag to move, double-click to reset)", "定規の原点(ドラッグで移動、ダブルクリックでリセット)"), + ("Rulers", "定規"), + ("Run", "実行"), + ("Sample Format: {}", "サンプル形式:{}"), + ("Samples Per Frame:", "フレームあたりのサンプル数:"), + ("Save from #{}", "#{}から保存"), + ("Saved {}", "保存日時:{}"), + ("Script… (⌘/Ctrl+Enter runs)", "スクリプト…(⌘/Ctrl+Enterで実行)"), + ("Search commands or shortcuts", "コマンドまたはショートカットを検索"), + ("Select", "選択"), + ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), + ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), + ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", + ), + ("Select a text layer", "テキストレイヤーを選択してください"), + ("Select an item in the Project panel", "プロジェクトパネルで項目を選択してください"), + ("Select animated properties to show them in the Graph Editor", "アニメーションのあるプロパティを選択すると、グラフエディターに表示されます"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "リーダーピンを選択してから、追従させるピン(髪、布、しっぽなど)をShift+クリック", + ), + ("Set Ground Plane and Origin", "地面と原点を設定"), + ("Set Paint Colors to Black and White (D)", "ペイントカラーを黒と白に設定(D)"), + ("Settings", "設定"), + ("Settings Name:", "設定名:"), + ("Shadow Darkness", "シャドウの暗さ"), + ("Shadow Diffusion", "シャドウの拡散"), + ("Shadows", "シャドウ"), + ("Shape…", "シェイプ…"), + ("Show", "表示"), + ("Show Layer Name", "レイヤー名を表示"), + ("Show Source Name", "ソース名を表示"), + ("Show me", "やり方を見る"), + ("Show:", "表示:"), + ("Shutter Angle:", "シャッター角度:"), + ("Shutter Phase:", "シャッターフェーズ:"), + ("Size", "サイズ"), + ("Skip", "スキップ"), + ("Snapping", "スナップ"), + ("Snapping options", "スナップオプション"), + ("Snapshot", "スナップショット"), + ("Solo Supported Properties", "対応プロパティをソロ表示"), + ("Solo Switches: {}", "ソロスイッチ:{}"), + ("Sort", "並べ替え"), + ("Spacing:", "間隔:"), + ("Spatial Interpolation:", "空間補間法:"), + ("Speed:", "速度:"), + ("Start Timecode:", "開始タイムコード:"), + ("Start: {} Duration: {}", "開始:{} デュレーション:{}"), + ("Step {} of {}: {}", "ステップ{}/{}:{}"), + ("Stop {} at {}°", "ストップ{}:{}°"), + ("Stored in: {}", "保存先:{}"), + ("Stretch", "伸縮"), + ("Stretch Factor:", "伸縮率:"), + ("Stylistic Sets", "スタイルセット"), + ("Subpixel Positioning", "サブピクセル位置決め"), + ("Swap Paint Colors (X)", "ペイントカラーを入れ替え(X)"), + ("System Default", "システムの既定"), + ("Table", "表形式"), + ("Template name", "テンプレート名"), + ("Template: {}\n{}", "テンプレート:{}\n{}"), + ("Temporal Interpolation:", "時間補間法:"), + ("The Media Browser is not available here (use File ▸ Import)", "ここではメディアブラウザーを使用できません(「ファイル ▸ 読み込み」を使ってください)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "赤・緑・青・アルファとして表示するOpenEXRレイヤー"), + ("The render queue is empty.", "レンダーキューは空です。"), + ("This ScriptUI panel has closed", "このScriptUIパネルは閉じられました"), + ("This change will create a new solid for this layer.", "この変更により、このレイヤー用に新しい平面が作成されます。"), + ("Time", "時間"), + ("Time Stretch…", "時間伸縮…"), + ("Time:", "時間:"), + ("Time: {}", "時間:{}"), + ("Title/Action Safe", "タイトル/アクションセーフ"), + ("Title/action safe", "タイトル/アクションセーフ"), + ("Toggle Switches / Modes", "スイッチ/モードを切り替え"), + ("Track Name:", "トラック名:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "学習済みモデルはデスクトップアプリで利用できます。ブラウザー版では従来のエンジンを使用します。", + ), + ("Transform", "トランスフォーム"), + ("Transparency grid", "透明グリッド"), + ("Tutorial complete", "チュートリアル完了"), + ("Two-Node Camera", "2ノードカメラ"), + ("Type", "種類"), + ("Type a command or effect…", "コマンドまたはエフェクトを入力…"), + ("URL:", "URL:"), + ("Undock Panel", "パネルのドッキングを解除"), + ("Unlink {}", "{}のリンクを解除"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "このオプションを使用すると、現在選択しているレイヤーをまとめて新しい中間コンポジションに配置します。", + ), + ("VR Environments", "VR環境"), + ("Value:", "値:"), + ("Variable Font Axes", "可変フォントの軸"), + ("Video Codec: {}", "ビデオコーデック:{}"), + ("View:", "表示:"), + ("Welcome to EffectCraft", "EffectCraftへようこそ"), + ("Width:", "幅:"), + ("You can undo this.", "この操作は取り消せます。"), + ("Your changes will be lost if you don't save them.", "保存しないと、変更内容は失われます。"), + ("Zoom", "ズーム"), + ("built-in", "組み込み"), + ("frames per second", "フレーム/秒"), + ("template name", "テンプレート名"), + ("{} contributors · {} commits", "コントリビューター{}人 · コミット{}件"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}、8bpcフレームあたり{}MB"), + ("{} — coming soon", "{} — 近日対応予定"), + ("{} — double-click to open, {}-click to reveal", "{} — ダブルクリックで開く、{}+クリックで場所を表示"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), +]; + const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ (" OK ", " 确定 "), (" Delete ", " 删除 "), @@ -88,7 +520,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ), ("About EffectCraft", "关于 EffectCraft"), ("About...", "关于..."), + ("Active", "使用中"), ("Active Camera", "活动摄像机"), + ("Alpha", "Alpha"), ("Adapt Feature On Every Frame", "每帧都适应特征"), ("Adaptive Sample Limit:", "自适应采样限制:"), ("Add Animator", "添加动画制作器"), @@ -109,6 +543,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), ("Anchor:", "定位点:"), ("Aperture", "光圈"), + ("Appearance Mode", "外观模式"), + ("Appearance Mode: {}", "外观模式:{}"), ("Apply Dimensions:", "应用尺寸:"), ("Apply Motion To:", "将运动应用于:"), ("Arrange", "排列"), @@ -121,6 +557,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Blending Mode", "混合模式"), ("Blur", "模糊"), ("Blur Level", "模糊级别"), + ("Boundary", "边界"), ("Browser Storage", "浏览器存储"), ("Browse…", "浏览…"), ("Brush Dynamics", "画笔动态"), @@ -129,7 +566,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Casts Shadows", "投射阴影"), ("Change Renderer…", "更改渲染器…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"), - ("Channel", "色版"), + ("Channel", "通道"), ("Channel:", "通道:"), ("Chapter:", "章节:"), ("Choose...", "选择..."), @@ -167,6 +604,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "连续(将出点锁定到入点)"), ("Cue Point", "提示点"), ("Current Render", "当前渲染"), + ("Dark", "深色"), + ("Dark Theme", "深色主题"), ("Darker", "更深"), ("Default Output:", "默认输出:"), ("Default: {}", "默认:{}"), @@ -229,6 +668,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Frame Rate:", "帧速率:"), ("Frame Render Time {}ms", "帧渲染时间 {} 毫秒"), ("Frame Target:", "帧目标:"), + ("Freeze", "冻结"), ("Grab bag", "列表"), ("Grid", "网格"), ("Guide Layers: {}", "参考图层:{}"), @@ -263,6 +703,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "将所有属性保留在“{}”中"), ("Licence: {}", "许可:{}"), ("Ligatures", "连字"), + ("Light", "浅色"), + ("Light Theme", "浅色主题"), ("Light Type", "灯光类型"), ("Lighter", "更浅"), ("Link Selected Property", "链接所选属性"), @@ -285,7 +727,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("More ArtCraft apps", "更多 ArtCraft 应用"), ("More tutorials", "更多教程"), ("Motion Blur", "运动模糊"), - ("Motion Blur: {}", "动态模糊:{}"), + ("Motion Blur: {}", "运动模糊:{}"), ("Motion Sketch: drag to record", "动态草图:拖动以记录"), ("Move Down", "下移"), ("Move Up", "上移"), @@ -335,6 +777,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Original Duration:", "原始持续时间:"), ("Output Cycle: {}", "输出循环:{}"), ("Output To:", "输出到:"), + ("Overlay", "叠加"), ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), @@ -359,6 +802,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Protected Region", "受保护区域"), ("Proxy Use: {}", "代理使用:{}"), ("Push All to Comp", "全部推送到合成"), + ("RGB", "RGB"), ("Radius", "半径"), ("Rate Control: {}", "比特率控制:{}"), ("Recent", "最近使用"), @@ -432,6 +876,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Start Timecode:", "开始时间码:"), ("Start: {} Duration: {}", "开始:{} 持续时间:{}"), ("Step {} of {}: {}", "第 {} 步,共 {} 步:{}"), + ("Stop", "停止"), ("Stop {} at {}°", "停止点 {} 位于 {}°"), ("Stored in: {}", "存储位置:{}"), ("Stretch", "伸展"), @@ -439,6 +884,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Stylistic Sets", "样式集"), ("Subpixel Positioning", "次像素定位"), ("Swap Paint Colors (X)", "交换绘画颜色(X)"), + ("Sync with System", "与系统同步"), ("System Default", "系统默认"), ("Table", "表格"), ("Template name", "模板名称"), @@ -453,6 +899,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Time Stretch…", "时间伸缩…"), ("Time:", "时间:"), ("Time: {}", "时间:{}"), + ("Title/Action Safe", "字幕/动作安全框"), ("Title/action safe", "字幕/动作安全框"), ("Toggle Switches / Modes", "切换开关/模式"), ("Track Name:", "跟踪名称:"), @@ -465,6 +912,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Unfreeze", "解冻"), ("Unlink {}", "取消链接 {}"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -490,6 +938,64 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl 拖动定位点可记录其运动"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} 已被以下项目使用:"), + // Strings a panel used to hand to a helper as a bare literal: the helper draws them (the + // viewer's toolbar tooltips and popup items, the solid dialog's checkbox), so the lookup has + // to reach the catalog from inside the helper. Kept at the end so the catalogs stay parallel. + ("3D Reference Axes", "3D 参考轴"), + ("Affect all layers that use this solid", "影响使用该纯色的所有图层"), + ("Choose grid and guide options", "选择网格和参考线选项"), + ("Colorize", "着色"), + ("Custom", "自定义"), + ("Custom...", "自定义…"), + ("Fit", "适合"), + ("Fit up to 100%", "适合(最大 100%)"), + ("Full", "完整"), + ("Half", "二分之一"), + ("Only this layer uses this solid.", "仅此图层使用该纯色。"), + ("Proportional Grid", "比例网格"), + ("Quarter", "四分之一"), + ("Region of Interest", "目标区域"), + ("Reset Exposure", "重置曝光"), + ("Show Channel and Color Management Settings", "显示通道和色彩管理设置"), + ("Show Snapshot (hold)", "显示快照(按住)"), + ("Take Snapshot", "拍摄快照"), + ("Third", "三分之一"), + ("Toggle Mask and Shape Path Visibility", "切换蒙版与形状路径可见性"), + ("Toggle Transparency Grid", "切换透明网格"), + // Technical terms and proper nouns: the project settings, the import and export forms, the + // viewer's channel and Fast Previews popups, the keyframe dialogs and the About window. The + // terms that are the same in Chinese (Display P3, HDTV (Rec. 709), RGB, Alpha, H.264, ProRes…) + // need no row: a missing row falls back to the English source, which is the decision. + ("16 bits per channel", "每通道 16 位"), + ("32 bits per channel (float)", "每通道 32 位(浮点)"), + ("8 bits per channel", "每通道 8 位"), + ("Adaptive Resolution", "自适应分辨率"), + ("Adobe-style built-in", "Adobe 风格内置"), + ("Blue", "蓝色"), + ("Current Settings", "当前设置"), + ("Disable Per-character 3D", "停用逐字 3D 化"), + ("Draft", "草图"), + ("Enable Per-character 3D", "启用逐字 3D 化"), + ("Fast Draft", "快速草图"), + ("Fast Previews (Draft 3D, Faster Effects)", "快速预览(3D 草图、更快效果)"), + ("Green", "绿色"), + ("Import Image Sequence", "导入图像序列"), + ("Import PDF / Illustrator File", "导入 PDF / Illustrator 文件"), + ("Import Photoshop File", "导入 Photoshop 文件"), + ("Interpret Unlabeled Alpha As", "将未标记的 Alpha 解释为"), + ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), + ("Mercury GPU Acceleration ({})", "Mercury GPU 加速({})"), + ("Mercury Software Only", "仅 Mercury 软件"), + ("OCIO (built-in ACES config)", "OCIO(内置 ACES 配置)"), + ("Off (Final Quality)", "关闭(最终质量)"), + ("OpenGL (compatibility)", "OpenGL(兼容)"), + ("Opus Bitrate: {} kbps", "Opus 比特率:{} kbps"), + ("RGB Straight", "RGB 直接"), + ("Red", "红色"), + ("Tone map", "色调映射"), + ("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"), + ("Wireframe", "线框"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -505,7 +1011,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ), ("About EffectCraft", "關於 EffectCraft"), ("About...", "關於..."), + ("Active", "使用中"), ("Active Camera", "活動攝影機"), + ("Alpha", "Alpha"), ("Adapt Feature On Every Frame", "每個影格都適應特徵"), ("Adaptive Sample Limit:", "自適應取樣限制:"), ("Add Animator", "添加動畫制作器"), @@ -526,6 +1034,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), ("Anchor:", "釘選點:"), ("Aperture", "光圈"), + ("Appearance Mode", "外觀模式"), + ("Appearance Mode: {}", "外觀模式:{}"), ("Apply Dimensions:", "應用尺寸:"), ("Apply Motion To:", "將運動應用於:"), ("Arrange", "排列"), @@ -538,6 +1048,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Blending Mode", "混合模式"), ("Blur", "模糊"), ("Blur Level", "模糊級別"), + ("Boundary", "邊界"), ("Browser Storage", "瀏覽器儲存"), ("Browse…", "瀏覽…"), ("Brush Dynamics", "筆刷動態"), @@ -584,6 +1095,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "連續(將出點鎖定到入點)"), ("Cue Point", "提示點"), ("Current Render", "目前算圖"), + ("Dark", "深色"), + ("Dark Theme", "深色主題"), ("Darker", "更深"), ("Default Output:", "預設輸出:"), ("Default: {}", "預設:{}"), @@ -646,6 +1159,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Frame Rate:", "影格速率:"), ("Frame Render Time {}ms", "影格算圖時間 {} 毫秒"), ("Frame Target:", "幀目標:"), + ("Freeze", "凍結"), ("Grab bag", "清單"), ("Grid", "格點"), ("Guide Layers: {}", "參考圖層:{}"), @@ -680,6 +1194,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "將所有屬性保留在“{}”中"), ("Licence: {}", "許可:{}"), ("Ligatures", "連字"), + ("Light", "淺色"), + ("Light Theme", "淺色主題"), ("Light Type", "燈光類型"), ("Lighter", "更淺"), ("Link Selected Property", "鏈接所選屬性"), @@ -752,6 +1268,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Original Duration:", "原始持續時間:"), ("Output Cycle: {}", "輸出循環:{}"), ("Output To:", "輸出到:"), + ("Overlay", "疊加"), ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), @@ -776,6 +1293,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Protected Region", "受保護區域"), ("Proxy Use: {}", "代理使用:{}"), ("Push All to Comp", "全部推送到合成"), + ("RGB", "RGB"), ("Radius", "半徑"), ("Rate Control: {}", "位元速率控制:{}"), ("Recent", "最近使用"), @@ -849,6 +1367,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Start Timecode:", "開始時間碼:"), ("Start: {} Duration: {}", "開始:{} 持續時間:{}"), ("Step {} of {}: {}", "第 {} 步,共 {} 步:{}"), + ("Stop", "停止"), ("Stop {} at {}°", "停止點 {} 位於 {}°"), ("Stored in: {}", "儲存位置:{}"), ("Stretch", "伸展"), @@ -856,6 +1375,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Stylistic Sets", "樣式集"), ("Subpixel Positioning", "次像素定位"), ("Swap Paint Colors (X)", "交換繪畫顏色(X)"), + ("Sync with System", "與系統同步"), ("System Default", "系統預設"), ("Table", "表格"), ("Template name", "範本名稱"), @@ -870,6 +1390,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Time Stretch…", "時間伸縮…"), ("Time:", "時間:"), ("Time: {}", "時間:{}"), + ("Title/Action Safe", "字幕/動作安全框"), ("Title/action safe", "字幕/動作安全框"), ("Toggle Switches / Modes", "切換開關/模式"), ("Track Name:", "追蹤名稱:"), @@ -882,6 +1403,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Unfreeze", "解凍"), ("Unlink {}", "取消鏈接 {}"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -907,6 +1429,491 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl 拖曳釘選點可記錄其運動"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} 已被以下專案使用:"), + // The same rows as the Simplified catalog (the tests require both lists to match). The + // Traditional wording is the translator's call and is left as the English source for now. + ("3D Reference Axes", "3D Reference Axes"), + ("Affect all layers that use this solid", "Affect all layers that use this solid"), + ("Choose grid and guide options", "Choose grid and guide options"), + ("Colorize", "Colorize"), + ("Custom", "Custom"), + ("Custom...", "Custom..."), + ("Fit", "Fit"), + ("Fit up to 100%", "Fit up to 100%"), + ("Full", "Full"), + ("Half", "Half"), + ("Only this layer uses this solid.", "Only this layer uses this solid."), + ("Proportional Grid", "Proportional Grid"), + ("Quarter", "Quarter"), + ("Region of Interest", "Region of Interest"), + ("Reset Exposure", "Reset Exposure"), + ("Show Channel and Color Management Settings", "Show Channel and Color Management Settings"), + ("Show Snapshot (hold)", "Show Snapshot (hold)"), + ("Take Snapshot", "Take Snapshot"), + ("Third", "Third"), + ("Toggle Mask and Shape Path Visibility", "Toggle Mask and Shape Path Visibility"), + ("Toggle Transparency Grid", "Toggle Transparency Grid"), + ("16 bits per channel", "16 bits per channel"), + ("32 bits per channel (float)", "32 bits per channel (float)"), + ("8 bits per channel", "8 bits per channel"), + ("Adaptive Resolution", "Adaptive Resolution"), + ("Adobe-style built-in", "Adobe-style built-in"), + ("Blue", "藍色"), + ("Current Settings", "Current Settings"), + ("Disable Per-character 3D", "Disable Per-character 3D"), + ("Draft", "Draft"), + ("Enable Per-character 3D", "Enable Per-character 3D"), + ("Fast Draft", "Fast Draft"), + ("Fast Previews (Draft 3D, Faster Effects)", "Fast Previews (Draft 3D, Faster Effects)"), + ("Green", "綠色"), + ("Import Image Sequence", "Import Image Sequence"), + ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), + ("Import Photoshop File", "Import Photoshop File"), + ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), + ("Join the ArtCraft Discord", "Join the ArtCraft Discord"), + ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), + ("Mercury Software Only", "Mercury Software Only"), + ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), + ("Off (Final Quality)", "Off (Final Quality)"), + ("OpenGL (compatibility)", "OpenGL (compatibility)"), + ("Opus Bitrate: {} kbps", "Opus Bitrate: {} kbps"), + ("RGB Straight", "RGB Straight"), + ("Red", "紅色"), + ("Tone map", "Tone map"), + ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), + ("Wireframe", "Wireframe"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), +]; + +/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. +/// Technical names, key names and substituted project content keep their original spelling. +const UKRAINIAN: &[(&str, &str)] = &[ + (" OK ", " Гаразд "), + (" Delete ", " Видалити "), + (" Open Auto-Save ", " Відкрити автозбереження "), + ("(missing)", "(відсутній)"), + ("360 View Orientation", "Орієнтація огляду 360°"), + ("3:2 Pulldown: {}", "Перетворення кадрів 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Незалежно розроблена програма з відкритим кодом для компонування анімованої графіки та візуальних ефектів: працює на macOS, Windows і Linux, а також у браузері. Належить до родини творчих програм ArtCraft.", + ), + ("About EffectCraft", "Про EffectCraft"), + ("About...", "Про програму..."), + ("Active Camera", "Активна камера"), + ("Adapt Feature On Every Frame", "Адаптувати ознаку в кожному кадрі"), + ("Adaptive Sample Limit:", "Межа адаптивних вибірок:"), + ("Add Animator", "Додати аніматор"), + ("Add Comment", "Додати коментар"), + ("Add Formatting", "Додати форматування"), + ("Add Group", "Додати групу"), + ("Add Mirror", "Додати дзеркало"), + ("Add Selected As", "Додати виділене як"), + ("Add a comment", "Додати коментар"), + ("Add a shape, fill, stroke or path operation", "Додати фігуру, заливку, обведення або операцію з контуром"), + ("Add to Composition", "Додати до композиції"), + ("Add to Favorites", "Додати до обраного"), + ("Add:", "Додати:"), + ("Adjust Exposure (stops)", "Налаштувати експозицію (ступені)"), + ("Adjust composition duration to the time span of the selected layers", "Припасувати тривалість композиції до часового діапазону виділених шарів"), + ("Advanced 3D", "Розширений 3D"), + ("Align Layers to:", "Вирівняти шари відносно:"), + ("All six face cameras turn together; the scene is not changed.", "Усі шість камер граней повертаються разом; сцена залишається без змін."), + ("Anchor:", "Опорна точка:"), + ("Aperture", "Діафрагма"), + ("Apply Dimensions:", "Застосувати виміри:"), + ("Apply Motion To:", "Застосувати рух до:"), + ("Arrange", "Упорядкувати"), + ("Assign", "Призначити"), + ("Auto", "Автоматично"), + ("Auto-saves", "Автозбереження"), + ("Back", "Ззаду"), + ("Background Color:", "Колір тла:"), + ("Blend Mode:", "Режим змішування:"), + ("Blending Mode", "Режим змішування"), + ("Blur", "Розмиття"), + ("Blur Level", "Рівень розмиття"), + ("Browser Storage", "Сховище браузера"), + ("Browse…", "Огляд…"), + ("Brush Dynamics", "Динаміка пензля"), + ("Caching frames…", "Кешування кадрів…"), + ("Cancel", "Скасувати"), + ("Casts Shadows", "Відкидає тіні"), + ("Change Renderer…", "Змінити рушій рендерингу…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Зміна клавіатурного скорочення створює власний набір; стандартний залишається без змін.", + ), + ("Channel", "Канал"), + ("Channel:", "Канал:"), + ("Chapter:", "Розділ:"), + ("Choose...", "Вибрати..."), + ("Classic 3D", "Класичний 3D"), + ("Clear", "Очистити"), + ("Clear list", "Очистити список"), + ("Click a key to see its commands.", "Клацніть клавішу, щоб переглянути її команди."), + ("Click adds a point (½ height = no change)", "Клацніть, щоб додати точку (½ висоти = без змін)"), + ("Click adds a point; drag one off to remove", "Клацніть, щоб додати точку; перетягніть її за межі, щоб вилучити"), + ("Click to make this viewer active", "Клацніть, щоб зробити цю область перегляду активною"), + ("Click to reveal the expression in the timeline", "Клацніть, щоб показати вираз на шкалі часу"), + ("Click to show the active composition here", "Клацніть, щоб показати тут активну композицію"), + ("Click, then click in the Composition panel", "Клацніть тут, а потім на панелі «Композиція»"), + ("Clone Options", "Параметри клонування"), + ("Clone Source Overlay:", "Накладання джерела клонування:"), + ("Close", "Закрити"), + ("Close Panel", "Закрити панель"), + ("Close tutorial", "Закрити урок"), + ("Color", "Колір"), + ("Color Depth: {}", "Глибина кольору: {}"), + ("Color:", "Колір:"), + ("Colour map", "Карта кольорів"), + ("Columns", "Стовпці"), + ("Comment:", "Коментар:"), + ("Community", "Спільнота"), + ("Composition", "Композиція"), + ("Composition Mini-Flowchart", "Мініблок-схема композиції"), + ("Composition Name:", "Назва композиції:"), + ("Composition Settings ▸ 3D Renderer", "Налаштування композиції ▸ Рушій 3D-рендерингу"), + ("Composition Settings…", "Налаштування композиції…"), + ("Composition marker bin: drag a marker to the time ruler", "Список маркерів композиції: перетягніть маркер на часову лінійку"), + ("Cone Angle", "Кут конуса"), + ("Cone Feather", "Розтушовування конуса"), + ("Constrain Proportions", "Зберігати пропорції"), + ("Continuous (Lock Outgoing to Incoming)", "Безперервна (прирівняти вихідну швидкість до вхідної)"), + ("Cue Point", "Сигнальна точка"), + ("Current Render", "Поточний рендеринг"), + ("Darker", "Темніше"), + ("Default Output:", "Стандартний вихід:"), + ("Default: {}", "Стандартне: {}"), + ("Defaults", "Стандартні значення"), + ("Delete", "Видалити"), + ("Delete Selected Points", "Видалити виділені точки"), + ("Delete selected project items", "Видалити виділені елементи проєкту"), + ("Delete this template", "Видалити цей шаблон"), + ("Depth of Field", "Глибина різкості"), + ("Discord", "Discord"), + ("Distribute Layers:", "Розподілити шари:"), + ("Don't Recover", "Не відновлювати"), + ("Don't Save", "Не зберігати"), + ("Double-click a layer to open it in the Layer panel", "Двічі клацніть шар, щоб відкрити його на панелі «Шар»"), + ("Double-click footage in the Project panel to open it here", "Двічі клацніть матеріал на панелі «Проєкт», щоб відкрити його тут"), + ("Double-click to edit the comment", "Двічі клацніть, щоб змінити коментар"), + ("Download", "Завантажити"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Перетягніть сюди властивості зі шкали часу,\nабо скористайтеся командою «Анімація > Додати властивість до основної графіки».", + ), + ("Drag to change", "Перетягніть, щоб змінити"), + ("Drag to create a guide", "Перетягніть, щоб створити напрямну"), + ("Drag to slip the source", "Перетягніть, щоб зсунути матеріал у межах шару"), + ("Drop Frame", "Пропуск номерів"), + ("Duplicate", "Дублювати"), + ("Duration:", "Тривалість:"), + ("Edit Gradient…", "Редагувати градієнт…"), + ("Edit Target: {}", "Ціль редагування: {}"), + ("Edit in the Timeline", "Редагувати на шкалі часу"), + ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), + ("Effects & Presets options", "Параметри ефектів і наборів"), + ("Effects: {}", "Ефекти: {}"), + ("Empty folder", "Порожня тека"), + ("Enable Depth of Field", "Увімкнути глибину різкості"), + ("Enable Expression", "Увімкнути вираз"), + ("Enhance", "Покращити"), + ("Essential Properties — {}", "Основні властивості — {}"), + ("Event", "Подія"), + ("Export Template…", "Експортувати шаблон…"), + ("Export...", "Експорт..."), + ("Expression Language Menu", "Меню мови виразів"), + ("Expression pick whip: drag onto a property", "Зв’язування виразу: перетягніть на властивість"), + ("Expression:", "Вираз:"), + ("Eyedropper: click, then click in the Composition panel", "Піпетка: клацніть тут, а потім на панелі «Композиція»"), + ("Falloff", "Згасання"), + ("Falloff Distance", "Відстань згасання"), + ("Favorites", "Обране"), + ("Field Render: {}", "Рендеринг полів: {}"), + ("Figures", "Цифри"), + ("Fill Target", "Ціль заливки"), + ("Finished", "Завершено"), + ("Flowchart: {}", "Блок-схема: {}"), + ("Focus Distance", "Відстань фокусування"), + ("Follow Feature Rotation and Scale", "Враховувати обертання та масштаб ознаки"), + ("Footage: {}", "Матеріал: {}"), + ("Format: {}", "Формат: {}"), + ("Frame Aspect Ratio: {}", "Пропорції кадру: {}"), + ("Frame Blending: {}", "Змішування кадрів: {}"), + ("Frame Rate:", "Частота кадрів:"), + ("Frame Render Time {}ms", "Час рендерингу кадру {} мс"), + ("Frame Target:", "Цільовий фрейм:"), + ("Grab bag", "Список"), + ("Grid", "Сітка"), + ("Guide Layers: {}", "Допоміжні шари: {}"), + ("Guides", "Напрямні"), + ("Height:", "Висота:"), + ("Hide This", "Приховати це"), + ("Histogram", "Гістограма"), + ("Hold In Place", "Утримувати на місці"), + ("Home", "Головна"), + ("If Confidence is Below", "Якщо достовірність нижча за"), + ("Import", "Імпортувати"), + ("Import and Add to Composition", "Імпортувати та додати до композиції"), + ("Import...", "Імпорт..."), + ("Imported media", "Імпортовані матеріали"), + ("Influence:", "Вплив:"), + ("Install from File…", "Встановити з файлу…"), + ("Intensity", "Інтенсивність"), + ("Inverted", "Інвертовано"), + ("Join the ArtCraft community on Discord", "Приєднайтеся до спільноти ArtCraft у Discord"), + ("Key: {}", "Клавіша: {}"), + ("LEARN", "НАВЧАННЯ"), + ("Label", "Мітка"), + ("Label Colors and Names:", "Кольори та назви міток:"), + ("Label:", "Мітка:"), + ("Latest auto-save: {}", "Останнє автозбереження: {}"), + ("Layer", "Шар"), + ("Layer controls", "Керування шарами"), + ("Layer {}", "Шар {}"), + ("Layer:", "Шар:"), + ("Layer: {}", "Шар: {}"), + ("Learn", "Навчання"), + ("Leave all attributes in \"{}\"", "Залишити всі атрибути в «{}»"), + ("Licence: {}", "Ліцензія: {}"), + ("Ligatures", "Лігатури"), + ("Light Type", "Тип світла"), + ("Lighter", "Світліше"), + ("Link Selected Property", "Зв’язати виділену властивість"), + ("Lock Aspect Ratio to {}", "Зафіксувати співвідношення сторін на {}"), + ("Lock to Zoom", "Прив’язати до масштабу"), + ("Locked: {} — {}", "Заблоковано: {} — {}"), + ("Log:", "Журнал:"), + ("Loop (Trace Path)", "Цикл (уздовж контуру)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Шрифти: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Меню коефіцієнта збільшення"), + ("Make Comp Size", "За розміром композиції"), + ("Masks", "Маски"), + ("Maximize Panel", "Розгорнути панель"), + ("Media Replacement", "Заміна матеріалу"), + ("Mesh:", "Сітка:"), + ("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"), + ("Models folder: {}", "Тека моделей: {}"), + ("Modifiers:", "Модифікатори:"), + ("More", "Більше"), + ("More ArtCraft apps", "Інші програми ArtCraft"), + ("More tutorials", "Інші уроки"), + ("Motion Blur", "Розмиття руху"), + ("Motion Blur: {}", "Розмиття руху: {}"), + ("Motion Sketch: drag to record", "Ескіз руху: перетягніть, щоб записати"), + ("Move Down", "Перемістити вниз"), + ("Move Up", "Перемістити вгору"), + ("Move to Project Root", "Перемістити до кореня проєкту"), + ("Name", "Назва"), + ("Name:", "Назва:"), + ("Navigation", "Навігація"), + ("New", "Створити"), + ("New Comp from Selection", "Нова композиція з виділеного"), + ("New Duration:", "Нова тривалість:"), + ("New composition name:", "Назва нової композиції:"), + ("New from Render Queue item:", "Створити з елемента черги рендерингу:"), + ("New from Template", "Створити із шаблону"), + ("Next", "Далі"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Гарна робота. Виберіть інший урок на вкладці «Навчання» головного екрана."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Середовищ VR ще немає. Створіть одне через «Композиція ▸ VR ▸ Створити середовище VR…».", + ), + ("No audio layer selected", "Аудіошар не виділено"), + ("No background jobs running", "Немає активних фонових завдань"), + ("No composition markers", "Немає маркерів композиції"), + ("No contributor data was built into this copy.", "Ця копія не містить даних про учасників."), + ( + "No device list from this host; the system default output is used.", + "Цей пристрій не надає списку пристроїв; використовується стандартний вихід системи.", + ), + ("No layer selected", "Шар не виділено"), + ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), + ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), + ("No nested compositions", "Немає вкладених композицій"), + ("No shortcut", "Немає клавіатурного скорочення"), + ("No unlocked layer selected", "Не виділено жодного розблокованого шару"), + ("No video", "Немає відео"), + ("None", "Немає"), + ("Not applied: tick it in the list to add it.", "Не застосовано: позначте в списку, щоб додати."), + ("Not assigned", "Не призначено"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Примітка: тіні відкидають лише шари з увімкненим параметром «Відкидати тіні»."), + ("Notify", "Сповістити"), + ("OK", "Гаразд"), + ("Offset:", "Зсув:"), + ("On ({}) stop:", "Після зупинки ({}):"), + ("One-Node Camera", "Одновузлова камера"), + ("Opacity:", "Непрозорість:"), + ("Open Composition", "Відкрити композицію"), + ("Open Last Saved Project", "Відкрити останній збережений проєкт"), + ("Open New Composition", "Відкрити нову композицію"), + ("Open Output", "Відкрити результат"), + ("Open a composition to build Essential Graphics", "Відкрийте композицію, щоб створити основну графіку"), + ("Open a composition to see its scopes", "Відкрийте композицію, щоб переглянути колірні діаграми"), + ("Open a composition to track motion.", "Відкрийте композицію, щоб відстежувати рух."), + ("Opus Tuning: {}", "Налаштування Opus: {}"), + ("Original Duration:", "Початкова тривалість:"), + ("Output Cycle: {}", "Цикл виводу: {}"), + ("Output To:", "Вивести до:"), + ("Parameter Name:", "Назва параметра:"), + ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), + ("Pixel Aspect Ratio:", "Пропорції пікселя:"), + ("Playing on Video Preview", "Відтворення на відеоперегляді"), + ("Post-Render Action: {}", "Дія після рендерингу: {}"), + ("Preserve frame rate when nested or in render queue", "Зберігати частоту кадрів у вкладених композиціях або черзі рендерингу"), + ("Preserve resolution when nested", "Зберігати роздільність у вкладених композиціях"), + ("Preset", "Набір"), + ("Preset:", "Набір:"), + ("Presets", "Набори"), + ("Previous", "Назад"), + ("Primary:", "Основний:"), + ("Process Before Match", "Обробити перед зіставленням"), + ("Profile: {}", "Профіль: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Глибина кольору проєкту: клацніть, щоб перемикати 8/16/32 bpc, або клацніть з Alt, щоб відкрити налаштування проєкту", + ), + ("Project: {}", "Проєкт: {}"), + ("Projects", "Проєкти"), + ("Property pick whip: drag onto a property", "Зв’язування властивості: перетягніть на властивість"), + ("Protected Region", "Захищена область"), + ("Proxy Use: {}", "Використання проксі: {}"), + ("Push All to Comp", "Застосувати все до композиції"), + ("Radius", "Радіус"), + ("Rate Control: {}", "Керування бітовою швидкістю: {}"), + ("Recent", "Нещодавні"), + ("Recording", "Запис"), + ("Remove", "Вилучити"), + ("Rename", "Перейменувати"), + ("Render: {} ms • UI {} fps{}", "Рендеринг: {} мс • Інтерфейс {} кадр/с{}"), + ("Renderer:", "Рушій рендерингу:"), + ("Request Persistent Storage", "Запросити постійне сховище"), + ("Reset", "Скинути"), + ("Reset Labels", "Скинути мітки"), + ("Reset Preset", "Скинути набір"), + ("Reset to Default", "Відновити стандартні значення"), + ("Reset to Saved Layout", "Відновити збережене компонування"), + ("Resolution/Down Sample Factor Popup", "Меню роздільності та коефіцієнта зменшення"), + ("Restore Panel Size", "Відновити розмір панелі"), + ("Reverse the order", "Обернути порядок"), + ("Revert All", "Відновити все"), + ("Roman Hanging Punctuation", "Висячі латинські розділові знаки"), + ("Roving:", "Автоматичний час:"), + ("Ruler zero point (drag to move, double-click to reset)", "Початок лінійки (перетягніть, щоб перемістити; двічі клацніть, щоб скинути)"), + ("Rulers", "Лінійки"), + ("Run", "Виконати"), + ("Sample Format: {}", "Формат вибірок: {}"), + ("Samples Per Frame:", "Вибірок на кадр:"), + ("Save from #{}", "Зберегти з #{}"), + ("Saved {}", "Збережено {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Скрипт… (⌘/Ctrl+Enter запускає)"), + ("Search commands or shortcuts", "Пошук команд або клавіатурних скорочень"), + ("Select", "Виділити"), + ("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."), + ("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"), + ("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.", + ), + ("Select a text layer", "Виділіть текстовий шар"), + ("Select an item in the Project panel", "Виділіть елемент на панелі «Проєкт»"), + ("Select animated properties to show them in the Graph Editor", "Виділіть анімовані властивості, щоб показати їх у редакторі графіків"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Виділіть провідну опорну точку, а потім клацніть із Shift точки, які мають рухатися за нею (волосся, тканина, хвости)", + ), + ("Set Ground Plane and Origin", "Задати площину землі та початок координат"), + ("Set Paint Colors to Black and White (D)", "Задати чорний і білий кольори малювання (D)"), + ("Settings", "Налаштування"), + ("Settings Name:", "Назва налаштувань:"), + ("Shadow Darkness", "Темнота тіні"), + ("Shadow Diffusion", "Розсіювання тіні"), + ("Shadows", "Тіні"), + ("Shape…", "Фігура…"), + ("Show", "Показати"), + ("Show Layer Name", "Показати назву шару"), + ("Show Source Name", "Показати назву матеріалу"), + ("Show me", "Показати мені"), + ("Show:", "Показати:"), + ("Shutter Angle:", "Кут затвора:"), + ("Shutter Phase:", "Фаза затвора:"), + ("Size", "Розмір"), + ("Skip", "Пропустити"), + ("Snapping", "Прив’язування"), + ("Snapping options", "Параметри прив’язування"), + ("Snapshot", "Знімок"), + ("Solo Supported Properties", "Лише підтримувані властивості"), + ("Solo Switches: {}", "Перемикачі соло: {}"), + ("Sort", "Сортувати"), + ("Spacing:", "Інтервал:"), + ("Spatial Interpolation:", "Просторова інтерполяція:"), + ("Speed:", "Швидкість:"), + ("Start Timecode:", "Початковий часовий код:"), + ("Start: {} Duration: {}", "Початок: {} Тривалість: {}"), + ("Step {} of {}: {}", "Крок {} з {}: {}"), + ("Stop {} at {}°", "Колірна точка {} на {}°"), + ("Stored in: {}", "Збережено в: {}"), + ("Stretch", "Розтягнення"), + ("Stretch Factor:", "Коефіцієнт розтягнення:"), + ("Stylistic Sets", "Стилістичні набори"), + ("Subpixel Positioning", "Субпіксельне позиціювання"), + ("Swap Paint Colors (X)", "Поміняти місцями кольори малювання (X)"), + ("System Default", "Стандартне системне"), + ("Table", "Таблиця"), + ("Template name", "Назва шаблону"), + ("Template: {}\n{}", "Шаблон: {}\n{}"), + ("Temporal Interpolation:", "Часова інтерполяція:"), + ("The Media Browser is not available here (use File ▸ Import)", "Браузер матеріалів тут недоступний (скористайтеся меню «Файл ▸ Імпорт»)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Шар OpenEXR, показаний у червоному, зеленому, синьому та альфа-каналі"), + ("The render queue is empty.", "Черга рендерингу порожня."), + ("This ScriptUI panel has closed", "Цю панель ScriptUI закрито"), + ("This change will create a new solid for this layer.", "Ця зміна створить новий матеріал суцільного кольору для цього шару."), + ("Time", "Час"), + ("Time Stretch…", "Розтягнення часу…"), + ("Time:", "Час:"), + ("Time: {}", "Час: {}"), + ("Title/Action Safe", "Безпечні області титрів і дії"), + ("Title/action safe", "Безпечні області титрів і дії"), + ("Toggle Switches / Modes", "Перемкнути перемикачі / режими"), + ("Track Name:", "Назва відстеження:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Навчені моделі доступні в настільній програмі; браузер використовує класичний рушій.", + ), + ("Transform", "Перетворення"), + ("Transparency grid", "Сітка прозорості"), + ("Tutorial complete", "Урок завершено"), + ("Two-Node Camera", "Двовузлова камера"), + ("Type", "Тип"), + ("Type a command or effect…", "Введіть команду або ефект…"), + ("URL:", "URL:"), + ("Undock Panel", "Від’єднати панель"), + ("Unlink {}", "Від’єднати {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Скористайтеся цим параметром, щоб розмістити виділені шари разом у новій проміжній композиції.", + ), + ("VR Environments", "Середовища VR"), + ("Value:", "Значення:"), + ("Variable Font Axes", "Осі змінного шрифту"), + ("Video Codec: {}", "Відеокодек: {}"), + ("View:", "Вигляд:"), + ("Welcome to EffectCraft", "Ласкаво просимо до EffectCraft"), + ("Width:", "Ширина:"), + ("You can undo this.", "Це можна скасувати."), + ("Your changes will be lost if you don't save them.", "Якщо не зберегти зміни, їх буде втрачено."), + ("Zoom", "Масштаб"), + ("built-in", "вбудований"), + ("frames per second", "кадрів за секунду"), + ("template name", "назва шаблону"), + ("{} contributors · {} commits", "Учасники: {} · Фіксації змін: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {} МБ на кадр 8bpc"), + ("{} — coming soon", "{} — незабаром"), + ("{} — double-click to open, {}-click to reveal", "{} — двічі клацніть, щоб відкрити; клацніть із {}, щоб показати розташування"), + ("⌘/Ctrl-drag a pin to record its motion", "Перетягніть опорну точку з ⌘/Ctrl, щоб записати її рух"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} використовується в:"), ]; #[cfg(test)] @@ -950,6 +1957,7 @@ mod tests { ("panels/precomp.rs", include_str!("../panels/precomp.rs")), ("panels/project.rs", include_str!("../panels/project.rs")), ("panels/properties.rs", include_str!("../panels/properties.rs")), + ("panels/roto_tool.rs", include_str!("../panels/roto_tool.rs")), ("panels/render_queue.rs", include_str!("../panels/render_queue.rs")), ("panels/rq_templates.rs", include_str!("../panels/rq_templates.rs")), ("panels/scopes_panel.rs", include_str!("../panels/scopes_panel.rs")), @@ -965,6 +1973,12 @@ mod tests { ("panels/viewer.rs", include_str!("../panels/viewer.rs")), ("panels/viewer_tools.rs", include_str!("../panels/viewer_tools.rs")), ("panels/viewers.rs", include_str!("../panels/viewers.rs")), + // Text the interface draws but does not own: the settings pages and the viewer's channel + // and Fast Previews lists come from the engine as data, so their source strings live here. + // Both files pass the literal scan below unchanged (they hold no drawn literals). + ("engine settings pages", include_str!("../../../engine/src/prefs.rs")), + ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), + ("engine viewer commands", include_str!("../../../engine/src/commands/viewer_cmds.rs")), ]; /// Call shapes that hand a string literal straight to the user. @@ -976,6 +1990,7 @@ mod tests { ".heading(\"", ".monospace(\"", ".on_hover_text(\"", + ".on_disabled_hover_text(\"", ".hint_text(\"", ".text(\"", ".prefix(\"", @@ -991,7 +2006,7 @@ mod tests { const UNITS: &[&str] = &[" px", "%", "·", "s", "ms", "°", "fps", " bpc", " mm", " s", " %", " pixels"]; fn catalogs() -> Vec<(&'static str, &'static str)> { - SIMPLIFIED_CHINESE.iter().copied().chain(TRADITIONAL_CHINESE.iter().copied()).collect() + JAPANESE.iter().copied().chain(SIMPLIFIED_CHINESE.iter().copied()).chain(TRADITIONAL_CHINESE.iter().copied()).chain(UKRAINIAN.iter().copied()).collect() } /// A row that translates nothing, repeats a source string, or changes the number of values the @@ -1001,6 +2016,11 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); + // Japanese and Ukrainian may lag the Chinese catalogs: a missing row falls back to English. + for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN)] { + let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); + assert!(extra.is_empty(), "the {name} catalog lists source strings the Chinese ones don't: {extra:?}"); + } let mut sorted = hans.clone(); sorted.sort_unstable(); let listed = sorted.len(); @@ -1019,7 +2039,7 @@ mod tests { fn english_and_unknown_text_pass_through() { set_current("en"); assert_eq!(tr("Composition Name:"), "Composition Name:"); - set_current("ja"); // no rows for Japanese yet + set_current("xx"); // a language without rows assert_eq!(tr("Composition Name:"), "Composition Name:"); set_current("zh-hans"); assert_eq!(tr("Composition Name:"), "合成名称:"); @@ -1027,6 +2047,21 @@ mod tests { set_current("en"); } + /// The same language preference used for the menus reaches panels immediately, and formatted + /// text keeps the caller's values intact even when they contain non-Ukrainian words. + #[test] + fn ukrainian_panels_translate_and_keep_project_content() { + set_current("uk"); + assert_eq!(tr("Composition Name:"), "Назва композиції:"); + assert_eq!(tr("Cancel"), "Скасувати"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Використання проксі: Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Крок 1 з 3: Colour"); + assert_eq!(tr_args("Template: {}\n{}", &[&"Title", &"My description"]), "Шаблон: Title\nMy description"); + set_current("en"); + assert_eq!(tr("Composition Name:"), "Composition Name:"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { @@ -1038,20 +2073,57 @@ mod tests { assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Proxy Use: Best"); } + /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a + /// row (a layer or file name) stays verbatim. + #[test] + fn japanese_rows_translate_the_panels() { + set_current("ja"); + assert_eq!(tr("Composition Name:"), "コンポジション名:"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); + set_current("en"); + } + /// Every row is asked for by a converted panel: a leftover row means the panel went back to its - /// English literal. + /// English literal. A string a helper draws for the panel (a toolbar tooltip, a popup item, a + /// checkbox label) is looked up inside that helper, so its English source only appears as a + /// literal there - `helper_labels_are_translated` keeps those helpers honest. #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { let plain = format!("tr({en:?})"); let formatted = format!("tr_args({en:?}"); + let bare = format!("{en:?}"); assert!( - CONVERTED.iter().any(|(_, src)| src.contains(&plain) || src.contains(&formatted)), + CONVERTED.iter().any(|(_, src)| src.contains(&plain) || src.contains(&formatted) || src.contains(&bare)), "{en:?} is in the catalog but no converted panel asks for it" ); } } + /// A label that a helper takes as an argument is drawn by the helper, so the helper has to ask + /// the catalog: `Checkbox::new` and `RadioButton::new` are not among the scanned call shapes + /// (the label is their second argument), which let the solid dialog's checkbox ship in English. + #[test] + fn helper_labels_are_translated() { + for (path, source) in CONVERTED { + for marker in ["Checkbox::new(", "RadioButton::new("] { + let mut from = 0; + while let Some(at) = source[from..].find(marker) { + let at = from + at; + let rest = &source[at + marker.len()..]; + let line = &rest[..rest.find('\n').unwrap_or(rest.len())]; + if let Some(q) = line.find('"') { + let before = line[..q].trim_end(); + assert!(before.ends_with("tr("), "{path}: the `{marker}` label `{}` is not in the catalog", &line[q..]); + } + from = at + marker.len(); + } + } + } + } + /// A newly hard-coded interface string fails here instead of shipping in English to every /// language. It is a heuristic - it looks at the call shapes the panels use - and it ignores /// one-character literals, which are punctuation or a key name rather than a sentence. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c2e335d2..52066f85 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -112,6 +112,13 @@ pub enum Icon { Globe, Code, Sparkle, + // appearance modes + /// Sync with System: a display on a stand. + Monitor, + /// Light: a disc with eight rays. + Sun, + /// Dark: a crescent. + Moon, } pub struct Pen16<'a> { @@ -616,6 +623,24 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { Sparkle => { pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); } + Monitor => { + pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2); + pen.line(&[(8.0, 11.0), (8.0, 13.8)]); + pen.line(&[(5.0, 13.8), (11.0, 13.8)]); + } + Sun => { + pen.circle(8.0, 8.0, 2.8); + for i in 0..8 { + let a = std::f32::consts::FRAC_PI_4 * i as f32; + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.4 * c, 8.0 + 6.4 * s)]); + } + } + Moon => { + // The outer edge of a disc, and the inner edge of the disc that eclipses it. + pen.arc(8.0, 8.0, 5.8, -20.0, 250.0); + pen.arc(10.6, 2.4, 4.6, 52.0, 178.0); + } PenAdd | PenDelete => { // A small pen nib with a plus or minus beside it. pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..dd0efc0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -92,9 +92,16 @@ pub enum Dialog { DeleteItems, } +/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send +/// winit no theme). `None` = the host does not know. +pub type SystemThemeFn = Box Option>; + /// Host hooks provided by the native app (file pickers etc.). #[derive(Default)] pub struct Hooks { + /// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads + /// the Linux desktop portal); without it, what egui reports. + pub system_theme: Option, pub pick_files: Option Vec>>, pub pick_save: Option Option>>, pub pick_open_project: Option Option>>, @@ -269,7 +276,8 @@ pub struct EffectcraftApp { /// Last reveal shortcut and when (double-press shortcuts such as LL). pub(crate) last_reveal: Option<(String, f64)>, /// Settings revision applied to the theme, tooltips and caches. - applied_prefs: Option, + /// The settings revision and system appearance (light?) last applied. + applied_prefs: Option<(u64, Option)>, /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). pub recovery: Option, /// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one @@ -367,13 +375,16 @@ impl EffectcraftApp { /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. pub fn apply_prefs(&mut self, ctx: &egui::Context) { - if self.applied_prefs == Some(self.session.prefs_revision) { + // Sync with System re-applies when the system appearance changes (the host wakes the UI; + // nothing polls). + let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None }; + if self.applied_prefs == Some((self.session.prefs_revision, system)) { return; } - self.applied_prefs = Some(self.session.prefs_revision); + self.applied_prefs = Some((self.session.prefs_revision, system)); let p = &self.session.prefs; - self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default(); - self.tokens = Tokens::from_prefs(p); + self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default(); + self.tokens = Tokens::from_prefs(p, self.ui.theme); theme::apply_visuals(ctx, &self.tokens); let tips = p.general.show_tool_tips; ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); @@ -399,13 +410,32 @@ impl EffectcraftApp { self } + /// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that + /// family's theme. pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) { - let name = match k { - theme::ThemeKind::Dark => "dark", - theme::ThemeKind::Darker => "darker", - theme::ThemeKind::Light => "light", + if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) { + self.ui.status = e; + } + self.apply_prefs(ctx); + } + + /// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux + /// desktop portal) wins over egui's, which Wayland compositors often leave empty. + pub fn system_light(&self, ctx: &egui::Context) -> Option { + self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light) + } + + /// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then + /// Dark, then Sync with System again. The light and dark theme choices are kept. + pub fn cycle_appearance(&mut self, ctx: &egui::Context) { + let next = match self.session.prefs.appearance.appearance_mode.as_str() { + "auto" => "light", + "light" => "dark", + _ => "auto", }; - let _ = self.set_pref("appearance.theme", json!(name)); + if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) { + self.ui.status = e; + } self.apply_prefs(ctx); } @@ -510,6 +540,12 @@ impl EffectcraftApp { && self.ui.viewer.extended.is_none() } + /// The UI draws its panels and handles input such as dropped files. Until the fonts are + /// installed (the first frames) it draws nothing, and that input goes unhandled. + pub fn is_ready(&self) -> bool { + self.fonts_ready + } + /// Provide the executable/embedding host's device notifications. The host installs the /// handlers once; this frontend never replaces handlers on a borrowed device. pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) { @@ -1472,6 +1508,8 @@ impl EffectcraftApp { header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); self.dock_area(ui, body); + #[cfg(not(target_arch = "wasm32"))] + prefs_live::dropped_files(self, ui, full); panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx); @@ -1544,6 +1582,34 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } + self.tick_footage_reload(ctx, now); + } + + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the + /// window comes back to the front (after saving in another app), stamp the footage files on a + /// worker thread and reload the ones that changed. + fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + use effectcraft_engine::footage_reload; + if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + self.ui.status = e.to_string(); + } + let (last_key, focus_key) = (egui::Id::new("footageReload.lastScan"), egui::Id::new("footageReload.focused")); + let focused = ctx.input(|i| i.focused); + let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); + ctx.data_mut(|d| d.insert_temp(focus_key, focused)); + if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { + return; + } + let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); + if now - last >= 2.0 || (focused && !was_focused) { + ctx.data_mut(|d| d.insert_temp(last_key, now)); + footage_reload::start_scan(&mut self.session); + } + if self.session.footage_scan.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } } /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call @@ -1713,3 +1779,74 @@ mod gpu_failure_tests { assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data); } } + +#[cfg(test)] +mod appearance_tests { + use super::*; + use crate::theme::ThemeKind; + + fn app() -> EffectcraftApp { + EffectcraftApp::new(effectcraft_engine::Session::default()) + } + + /// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System + /// again, showing the saved theme of each family without losing either choice. + #[test] + fn appearance_button_cycles_modes_without_losing_theme_choices() { + let ctx = egui::Context::default(); + let mut app = app(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + app.set_pref("appearance.darkTheme", json!("darker")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Darker); + for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] { + menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, mode); + assert_eq!(app.ui.theme, shown, "{mode}"); + assert_eq!(app.tokens.kind, shown); + assert_eq!(app.session.prefs.appearance.dark_theme, "darker"); + assert_eq!(app.session.prefs.appearance.light_theme, "light"); + } + } + + /// Sync with System follows the host's answer (the Linux portal) when it changes, and falls + /// back to Dark when nothing reports a system appearance. + #[test] + fn auto_follows_a_stubbed_system_theme() { + use std::sync::{Arc, Mutex}; + let ctx = egui::Context::default(); + let mut app = app(); + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let read = Arc::clone(&system); + app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner))); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change"); + *system.lock().unwrap() = None; + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark); + // A fixed mode ignores the system. + *system.lock().unwrap() = Some(egui::Theme::Light); + app.set_pref("appearance.appearanceMode", json!("dark")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark); + } + + /// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family. + #[test] + fn picking_a_theme_fixes_its_mode() { + let ctx = egui::Context::default(); + let mut app = app(); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, "light"); + assert_eq!(app.ui.theme, ThemeKind::Light); + menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, "dark"); + assert_eq!(app.session.prefs.appearance.dark_theme, "darker"); + assert_eq!(app.ui.theme, ThemeKind::Darker); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a31f4b39..7851779f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -46,6 +46,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.safeMargins", "Title/Action Safe", [], None), uic!("view.transparencyGrid", "Transparency Grid", [], None), uic!("view.fastPreviews", "Fast Previews", [], None), + uic!("view.theme.toggle", "Next Appearance Mode", [], None), uic!("view.theme.dark", "Theme: Dark", [], None), uic!("view.theme.darker", "Theme: Darker", [], None), uic!("view.theme.light", "Theme: Light", [], None), @@ -300,6 +301,14 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V None => Err("select a Render Queue item first".into()), }; } + // Shift+Delete deletes the Project panel's selection without asking; it does nothing in the + // other panels. + if id == "project.deleteWithoutConfirmation" { + if app.ui.focused != PanelKind::Project { + return Ok(Value::Null); + } + return crate::panels::delete_items::delete_confirmed(app, ctx, json!({})); + } let now = ctx.input(|i| i.time); // Closing a modified project asks to save it first; the command runs once answered. if crate::panels::unsaved::guard(app, id, ¶ms) { @@ -463,6 +472,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.viewer.res = Resolution::Auto; return Ok(Value::Null); } + if id == "view.theme.toggle" { + app.cycle_appearance(ctx); + return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode})); + } if let Some(th) = id.strip_prefix("view.theme.") { let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?; app.set_theme(ctx, k); @@ -668,7 +681,7 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String { } } -fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { +pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { let r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { app.ui.status = e.clone(); @@ -713,6 +726,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } // View ▸ New Viewer. "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), + // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. + "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, "window.scriptPanel" => { let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32; app.show_panel(PanelKind::ScriptPanel(id)); @@ -1125,6 +1140,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu /// Commands that need a file or folder path: ask the host's file dialog when `params` lacks one. fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option> { enum Ask { + Folder, Import, OpenProject, Save(&'static str), @@ -1151,6 +1167,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["jsx", "js"])), "effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])), "file.replaceFootage" => ("path", Ask::Import), + "file.relinkFootage" => ("folder", Ask::Folder), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), "file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")), "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), @@ -1169,6 +1186,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option = match ask { + Ask::Folder => { + let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; + f().map(Value::from) + } Ask::Import => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; let paths = f(&[ @@ -1455,8 +1476,27 @@ fn space_tap_id() -> egui::Id { egui::Id::new("spacebar-tap") } +/// The modifiers held when this frame's input began: the state the last pass ended with, if it +/// ran this (egui keeps only the state after the frame's events). Records this pass's state. +fn modifiers_at_start(ctx: &egui::Context) -> Option { + let id = egui::Id::new("shortcut-modifiers"); + let pass = ctx.cumulative_pass_nr(); + let now = ctx.input(|i| i.modifiers); + ctx.data_mut(|d| { + let last = d.get_temp::<(u64, egui::Modifiers)>(id); + d.insert_temp(id, (pass, now)); + last.and_then(|(p, m)| (p.checked_add(1) == Some(pass)).then_some(m)) + }) +} + +/// Only Shift held (Shift+Delete, not Ctrl/Alt+Shift+Delete). +fn only_shift(m: egui::Modifiers) -> bool { + m.shift && !m.alt && !m.ctrl && !m.command && !m.mac_cmd +} + /// Dispatch keyboard shortcuts (skipped while typing in a text field). pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { + let start = modifiers_at_start(ctx); // Dialog cancellation owns Escape even when a text field has keyboard focus. if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) { @@ -1465,6 +1505,8 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); } else { if app.dialog == Some(crate::Dialog::Settings) && let Some(p) = app.dialog_state.prefs_snapshot.take() @@ -1491,14 +1533,23 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { } // Text editing in the viewer takes the clipboard events itself. let clipboard = app.session.state.text_edit.is_none(); + let project = app.ui.focused == PanelKind::Project; let events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, - // Paste and their variants (Ctrl+Alt+C…) run. - let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND }; - i.events - .iter() - .filter_map(|e| match e { + // Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and + // `i.modifiers` is the state after all of the frame's events, so each is read with the + // modifiers where it is in the stream: walk the events from the frame's starting state. + // `None` when that isn't known. + let changed = i.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(false))); + let mut at = if changed { start } else { Some(i.modifiers) }; + let held = |at: Option| at.filter(|m| m.command).unwrap_or(egui::Modifiers::COMMAND); + let mut out = Vec::new(); + for e in &i.events { + match e { + egui::Event::ModifiersChanged(m) => at = Some(*m), + // egui forgets the modifiers held when the window loses focus. + egui::Event::WindowFocused(false) => at = Some(egui::Modifiers::NONE), egui::Event::Key { key, pressed: true, modifiers, repeat, .. } if !*repeat || matches!( @@ -1506,15 +1557,22 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown ) => { - Some((*key, *modifiers, true)) + out.push((*key, *modifiers, true)); } - egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)), - egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)), - egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)), - egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)), - _ => None, - }) - .collect() + egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false)), + egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true)), + // On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project + // panel has no clipboard: there a Cut with only Shift held is Shift+Delete (Delete + // Project Items Without Confirmation). Never when the modifiers aren't known. + egui::Event::Cut if clipboard && project && at.is_some_and(only_shift) => { + out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true)); + } + egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true)), + egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true)), + _ => {} + } + } + out }); // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it // comes as text. @@ -1550,6 +1608,10 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { if crate::panels::shortcut_editor::recording(app) { continue; } + if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + continue; + } // Escape in Settings is Cancel: restore the settings from when it opened. if app.dialog == Some(crate::Dialog::Settings) && let Some(p) = app.dialog_state.prefs_snapshot.take() diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index b2509341..e727b9f9 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -192,6 +192,10 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "服務" } else if crate::i18n::ukrainian(app) { "Служби" + } else if crate::i18n::spanish(app) { + "Servicios" + } else if crate::i18n::brazilian_portuguese(app) { + "Serviços" } else { "Services" } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 39b7580e..b18e4c8d 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -340,11 +340,13 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr Tab::Renderer => { ui.horizontal(|ui| { ui.label(tr("Renderer:")); - let r = - egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt("comp-renderer").selected_text(tr(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" })).show_ui( + ui, + |ui| { ui.selectable_value(&mut d.advanced_3d, false, tr("Classic 3D")); ui.selectable_value(&mut d.advanced_3d, true, tr("Advanced 3D")); - }); + }, + ); auto.add("dialog.comp.renderer", r.response.rect, "Renderer"); }); } diff --git a/crates/ui-egui/src/panels/delete_items.rs b/crates/ui-egui/src/panels/delete_items.rs index 7ccc936a..ca07fd18 100644 --- a/crates/ui-egui/src/panels/delete_items.rs +++ b/crates/ui-egui/src/panels/delete_items.rs @@ -1,7 +1,8 @@ //! Deleting Project items that compositions use asks first, as in After Effects: the Delete key //! and the Project panel's trash button (`project.delete` through the UI) show how many layers in //! how many compositions go with the items (`project.usage`). Items nothing uses are deleted at -//! once. Agents running `project.delete` with `engine.execute` are never asked. +//! once. Agents running `project.delete` with `engine.execute` are never asked, and neither is +//! Delete Project Items Without Confirmation (Shift+Delete in the Project panel). //! //! Automation ids: `dialog.deleteItems.delete`, `dialog.deleteItems.cancel`. @@ -16,8 +17,6 @@ use crate::{Dialog, EffectcraftApp}; #[derive(Clone, Debug, Default)] pub struct Pending { pub command: Option<(Value, Value)>, - /// The next `project.delete` runs without asking (the user answered Delete). - pub answered: bool, } fn plural(n: u64, one: &str, many: &str) -> String { @@ -27,7 +26,7 @@ fn plural(n: u64, one: &str, many: &str) -> String { /// Whether `id` needs the prompt first. When it does, the prompt opens holding the command and /// the caller must not run it (it runs once the user answers Delete). pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> bool { - if id != "project.delete" || std::mem::take(&mut app.dialog_state.delete_items.answered) { + if id != "project.delete" { return false; } let Ok(usage) = app.session.execute("project.usage", params.clone()) else { return false }; @@ -79,9 +78,14 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog = None; app.dialog_state.delete_items.command = None; if delete { - app.dialog_state.delete_items.answered = true; - if let Err(e) = crate::menus::invoke(app, ctx, "project.delete", params) { - app.ui.status = e; - } + // An error is already in the status bar. + let _ = delete_confirmed(app, ctx, params); } } + +/// Run `project.delete` with `params` without the prompt: this request is already confirmed (the +/// prompt's Delete, or Delete Project Items Without Confirmation). The confirmation goes with the +/// request, so it can't carry over to a later deletion. +pub fn delete_confirmed(app: &mut EffectcraftApp, ctx: &egui::Context, params: Value) -> Result { + crate::menus::run_engine(app, ctx, "project.delete", params) +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f123906b..08e7c302 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -158,7 +158,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke .show(ctx, |ui| { ui.set_width(size.x - 36.0); ui.set_min_height(size.y - 36.0); - ui.label(egui::RichText::new(title).font(Tokens::semibold(15.0)).color(t.tab_text_active)); + // The id stays keyed on the untranslated source, so switching language cannot move it. + ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); ui.add_space(10.0); egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body); }); @@ -291,7 +292,7 @@ fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut ); let foreground = if discord { Color32::WHITE } else { t.text }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, foreground); - p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), foreground); + p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), foreground); p.text( pos2(r.max.x - 12.0, r.center().y), Align2::RIGHT_CENTER, @@ -418,8 +419,8 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if editing.is_some() { ui.add_space(8.0); let r = ui - .add_enabled(shared, egui::Checkbox::new(&mut affect_all, "Affect all layers that use this solid")) - .on_disabled_hover_text("Only this layer uses this solid."); + .add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) + .on_disabled_hover_text(tr("Only this layer uses this solid.")); auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid"); if shared && !affect_all { ui.label(egui::RichText::new(tr("This change will create a new solid for this layer.")).color(t.text_dim).size(11.0)); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 99fe39e4..94d8e5a4 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -37,6 +37,21 @@ const DIAL_ROW: f32 = 64.0; const PARAM_NAME_X: f32 = 58.0; const TREE_INDENT: f32 = 14.0; +/// Selecting any descendant activates its owning effect without replacing the property +/// selection used by keyframe editing, copy and delete. +fn effect_active(g: &PropGroup, layer: effectcraft_engine::project::LayerId, selected: &[(effectcraft_engine::project::LayerId, u64)]) -> bool { + fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool { + if g.uid == uid { + return true; + } + if depth >= 16 { + return false; + } + g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(child) if contains(child, uid, depth + 1))) + } + selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0)) +} + fn selected_layer(app: &EffectcraftApp) -> Option { let comp = app.session.active_comp()?; let id = app.session.state.selected_layers.first()?; @@ -165,6 +180,10 @@ fn prop_row( let mut dims = vec![]; let cy = r.center().y; let uid = prop.uid; + if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(r) { + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": add}))); + } let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0)); if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) { let resp = ui.interact(swr, egui::Id::new(("ec-sw", uid)), Sense::click()); @@ -211,6 +230,8 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); + ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); @@ -756,7 +777,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, .min(4); let x0 = r.min.x + 30.0; let cy = r.min.y + 17.0; - let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect(); + let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")]; if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) { match chan_prop { Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))), @@ -845,7 +866,7 @@ fn levels_editor( g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4) }; if effect == "ec.color.levelsic" { - let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect(); + let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")]; if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel")) { app.ui.fx_levels_channel.insert(euid, n); @@ -972,7 +993,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Clicking empty space deselects the effects. let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click()); if bg.clicked() { - app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| g.uid == *u))); + app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)])))); } let has_clip = !app.session.state.effect_clipboard.is_empty(); let drag_id = egui::Id::new("ec-drag-effect"); @@ -986,7 +1007,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 2.0; let open = !app.ui.fx_closed.contains(&g.uid); let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid); - bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); + let active = effect_active(g, layer.id, &app.session.state.selected_props); + bp.rect_filled(r, 0.0, if active { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0)); if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid}))); @@ -1028,6 +1050,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Id::new(("ec-hdr", g.uid)), Sense::click_and_drag(), ); + hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name)); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); @@ -1077,6 +1100,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh)); y += eh; if er.max.y >= body.min.y && er.min.y <= body.max.y { + // Curves, Levels and other custom editors do not use prop_row. + // Clicking their controls must activate the owner as well. + if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) { + actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid}))); + } match effect.as_str() { "ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions), effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions), @@ -1153,11 +1181,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // --------------------------------------------------------------------------------------------- // Viewer hook +/// Collect point controls through visible parameter groups. Hidden optional slots do not +/// leave stray viewport handles. The depth cap also bounds malformed project trees. +fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx: &EvalCtx, depth: usize, out: &mut Vec<&'a Property>) { + if depth > 16 { + return; + } + for node in &g.children { + match node { + Node::Prop(p) if matches!(p.ui, ParamUi::Point) && scope.shown(layer, ectx, &p.match_id) => out.push(p), + Node::Group(child) if child.enabled && scope.shown(layer, ectx, &child.match_id) => { + visible_points(child, &scope.sub(&child.match_id), layer, ectx, depth + 1, out) + } + _ => {} + } + } +} + /// Effect Controls' part of the Composition viewer, called by the viewer after its own /// interaction (so these controls sit on top): /// -/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point -/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels; +/// - while a crosshair pick is armed, its handle previews the cursor position without editing +/// the property; the next click sets the point in layer space, and Esc cancels; +/// - an eyedropper pick samples the frame's colour on click; /// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls. /// /// `l2c` is the viewer's layer→comp matrix (it knows the 3D view). @@ -1172,6 +1218,7 @@ pub fn viewer_hook( let t = app.tokens; let ctx = ui.ctx().clone(); let mut actions: Actions = vec![]; + let mut point_preview = None; if let Some(pick) = app.ui.fx_pick.clone() { let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { app.ui.fx_pick = None; @@ -1197,6 +1244,9 @@ pub fn viewer_hook( ctx.set_cursor_icon(egui::CursorIcon::Crosshair); let c = map.to_comp(hp); app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { + point_preview = Some(hp); + } if pick.kind == "color" && keyer.is_none() && let Some(img) = &app.viewer_image @@ -1238,23 +1288,51 @@ pub fn viewer_hook( app.ui.fx_pick = None; app.ui.status.clear(); } - } else if app.ui.viewer.show_layer_controls { + } + let picking = app.ui.fx_pick.is_some(); + let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); + if app.ui.viewer.show_layer_controls || point_pick.is_some() { // ⊕ controls for the point parameters of the selected effects. - let sel = app.session.state.selected_props.clone(); + let mut sel = app.session.state.selected_props.clone(); + if let Some(pick) = &point_pick { + sel.push((effectcraft_engine::project::LayerId(pick.layer), pick.prop)); + } let selected = app.session.state.selected_layers.clone(); - for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) { + for layer in ectx + .comp + .layers + .iter() + .filter(|l| (selected.contains(&l.id) || point_pick.as_ref().is_some_and(|p| p.layer == l.id.0)) && l.is_active_at(ectx.time)) + { let Some(fx) = layer.effects() else { continue }; let m = l2c(ectx, layer); // Masks are in layer space, effect points in effect space. let fm = super::viewer::from_effect_space(ectx, layer, m); - for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) { - if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) { + let picked_prop = point_pick.as_ref().filter(|p| p.layer == layer.id.0).map(|p| p.prop); + for g in fx.groups().filter(|g| g.enabled && effect_active(g, layer.id, &sel)) { + // Puppet owns its pin handles and hit testing. Generic crosshairs would + // overlap the pins and steal their selection/rotation/scale gestures. + if matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_engine::effects::puppet::ID) { + continue; + } + if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) { super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions); } - let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect(); + let scope = FxScope { effect: &g.match_id, root: g, path: String::new() }; + let mut pts = Vec::new(); + visible_points(g, &scope, layer, ectx, 0, &mut pts); + // An explicit pick stays visible even when View > Show Layer Controls is off. + if !app.ui.viewer.show_layer_controls { + pts.retain(|pr| picked_prop == Some(pr.uid)); + } let screen: Vec = pts .iter() .map(|pr| { + if picked_prop == Some(pr.uid) + && let Some(pos) = point_preview + { + return pos; + } let v = ectx.value(layer, pr).as_vec2(); map.to_screen(fw::layer_to_comp(&fm, v)) }) @@ -1266,11 +1344,13 @@ pub fn viewer_hook( for (pr, s) in pts.iter().zip(screen) { let hr = Rect::from_center_size(s, vec2(16.0, 16.0)); let id = egui::Id::new(("viewer-fx-pt", pr.uid)); - let resp = ui.interact(hr, id, Sense::drag()); - let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE }; + // The picker owns the click. Preview handles must not steal it or start a drag. + let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag())); + let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE }; fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160)); fw::draw_crosshair(painter, s, 5.0, col); app.auto.add(&format!("viewer.effectPoint.{}", pr.uid), hr, &pr.name); + let Some(resp) = resp else { continue }; if resp.hovered() { ctx.set_cursor_icon(egui::CursorIcon::Move); resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name)); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index a9468451..c7f330b0 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -4,7 +4,7 @@ //! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a //! dialog does an agent can do with one `engine.execute`. -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::{Color32, vec2}; use serde_json::{Map, Value, json}; @@ -487,9 +487,11 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => { let st = &s.project.settings; + // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through + // tr_args: wrapping the assembled string in tr() would look up the backend's name. let gpu_label = match &s.accel { - Some(a) => format!("Mercury GPU Acceleration ({})", a.name()), - None => "Mercury GPU Acceleration (no GPU: software)".to_string(), + Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]), + None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(), }; let depth = match st.bit_depth.label() { l if l.starts_with("16") => 1, @@ -765,7 +767,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { for (i, fl) in f.fields.iter_mut().enumerate() { - ui.label(&fl.label); + ui.label(tr(&fl.label)); let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; @@ -801,7 +803,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { FieldKind::Note(text) => { ui.scope(|ui| { ui.set_width(220.0); - ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap()) + ui.add(egui::Label::new(egui::RichText::new(tr(text.as_str())).small()).wrap()) }) .inner .rect @@ -810,11 +812,11 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); egui::ComboBox::from_id_salt(("form", fl.key.as_str())) - .selected_text(cur) + .selected_text(tr(&cur)) .width(240.0) .show_ui(ui, |ui| { for (i, (l, _)) in options.iter().enumerate() { - ui.selectable_value(sel, i, l); + ui.selectable_value(sel, i, tr(l)); } }) .response @@ -879,11 +881,16 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } } +/// Close the information dialog without abandoning a pending Settings transaction. +pub(crate) fn close_info(app: &mut EffectcraftApp) { + app.dialog = app.dialog_state.prefs_snapshot.is_some().then_some(Dialog::Settings); +} + pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (title, body) = app.dialog_state.info.clone(); let mut close = false; super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| { - ui.label(body); + ui.label(tr(&body)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() { @@ -892,7 +899,7 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); }); if close { - app.dialog = None; + close_info(app); } } diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 3e170c12..6e1c5d4c 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -190,9 +190,10 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) { ui.add_enabled_ui(enabled, |ui| { - let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(opts[*sel]).show_ui(ui, |ui| { + // The options are the English source strings; the automation id and label stay English. + let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(tr(opts[*sel])).show_ui(ui, |ui| { for (i, o) in opts.iter().enumerate() { - ui.selectable_value(sel, i, *o); + ui.selectable_value(sel, i, tr(o)); } }); app.auto.add(id, r.response.rect, id); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2dcecc1d..9e78ffbc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use crate::dock::PanelKind; +use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -120,7 +121,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool()); let entries = animator_entries(per_char); - let labels: Vec = entries.iter().map(|(l, _)| l.clone()).collect(); + // The entries are the English source strings; the menu draws them translated. + let labels: Vec = entries.iter().map(|(l, _)| tr(l).to_string()).collect(); if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) { match entries[i].1 { "" => {} diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 583c800b..502b8410 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -183,7 +183,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren } v.push(("-".into(), Value::Null)); for kbps in [24u32, 32, 64, 96, 128, 192, 256] { - v.push((mark(o.opus_bitrate_kbps == kbps, &format!("Opus Bitrate: {kbps} kbps")), json!({"audioBitrate": kbps}))); + v.push((mark(o.opus_bitrate_kbps == kbps, &tr_args("Opus Bitrate: {} kbps", &[&kbps])), json!({"audioBitrate": kbps}))); } for (a, k) in [(OpusApplication::Audio, "audio"), (OpusApplication::Voip, "voice")] { v.push((mark(o.opus_application == a, &tr_args("Opus Tuning: {}", &[&(a.label())])), json!({"opusApplication": k}))); diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 9eacaad9..f9090981 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -8,6 +8,7 @@ use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer}; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; +use crate::i18n::tr; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -292,7 +293,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let t = app.tokens; let mut x = x; let view = app.session.state.roto.view.clone(); - for (id, label) in [("alpha", "Alpha"), ("alphaBoundary", "Boundary"), ("alphaOverlay", "Overlay")] { + for (id, label) in [("alpha", tr("Alpha")), ("alphaBoundary", tr("Boundary")), ("alphaOverlay", tr("Overlay"))] { let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(66.0, 20.0)); let on = view == id; if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("roto-view", id))).clicked() { @@ -309,7 +310,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let running = app.session.is_roto_running(); x += 8.0; let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(74.0, 20.0)); - let label = if frozen { "Unfreeze" } else { "Freeze" }; + let label = if frozen { tr("Unfreeze") } else { tr("Freeze") }; if widgets::text_button(ui, r, label, frozen, &t, egui::Id::new(("roto-freeze", uid))).clicked() { let id = if frozen { "roto.unfreeze" } else { "roto.freeze" }; if let Err(e) = app.session.execute(id, json!({"layer": layer.id.0, "effect": uid})) { @@ -320,7 +321,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte x += 80.0; if running { let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0)); - if widgets::text_button(ui, r, "Stop", false, &t, egui::Id::new("roto-stop")).clicked() { + if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::new("roto-stop")).clicked() { let _ = app.session.execute("roto.cancel", json!({})); } app.auto.add("layerPanel.roto.stop", r, "Stop"); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ac79d8f0..6a84d761 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -8,12 +8,12 @@ //! `settings.cancel`, `settings.previous`, `settings.next`. use crate::i18n::{tr, tr_args}; -use effectcraft_engine::prefs::{Item, Kind, Page, pages}; +use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages}; use effectcraft_engine::segment::Task; use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; -use crate::theme::Tokens; +use crate::theme::{ThemeKind, Tokens}; use crate::{Dialog, EffectcraftApp}; /// Open the dialog on a page, remembering the settings for Cancel. @@ -50,14 +50,14 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value match *item { Item::Section(title) => { ui.add_space(6.0); - ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.tab_text_active)); + ui.label(RichText::new(tr(title)).font(Tokens::semibold(12.5)).color(t.tab_text_active)); ui.separator(); } Item::Note(text) => { - ui.label(RichText::new(text).color(t.text_dim)); + ui.label(RichText::new(tr(text)).color(t.text_dim)); } Item::Button { label, command, params } => { - let r = ui.button(label); + let r = ui.button(tr(label)); reg(app, &format!("settings.button.{}", label.trim_end_matches('.')), &r, label); if r.clicked() { acts.push(Act::Run(command.into(), serde_json::from_str(params).unwrap_or(json!({})))); @@ -88,6 +88,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.label(RichText::new(tr("No device list from this host; the system default output is used.")).color(t.text_dim)); } } + // Appearance Mode with the light and dark theme cards (the per-mode themes are + // chosen on the cards). + Item::Setting { key: "appearance.appearanceMode", .. } => appearance_ui(app, ui, cur, t, acts), + Item::Setting { key: "appearance.lightTheme" | "appearance.darkTheme", .. } => {} Item::Setting { key, label, kind, live } => { let v = get(key); let id = format!("settings.{key}"); @@ -96,7 +100,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, label); + let r = ui.checkbox(&mut b, tr(label)); if r.changed() { acts.push(Act::Set(key.into(), json!(b))); } @@ -104,7 +108,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } _ => { ui.horizontal(|ui| { - ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{label}:")).truncate()); + ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); @@ -146,11 +150,11 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string); let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string(); egui::ComboBox::from_id_salt(("settings", key)) - .selected_text(shown) + .selected_text(tr(&shown)) .width(220.0) .show_ui(ui, |ui| { for (l, val) in opts { - if ui.selectable_label(*val == curv, *l).clicked() { + if ui.selectable_label(*val == curv, tr(l)).clicked() { acts.push(Act::Set(key.into(), json!(val))); } } @@ -201,6 +205,166 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } } +/// A small EffectCraft window painted with theme `kind`'s tokens: the Tools bar, the Project +/// panel, the Composition panel, the Preview and Properties column and the Timeline with its +/// layer bars. +fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { + use egui::{Rect, pos2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, 118.0), egui::Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 3.0, p.app_bg); + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(bar, 0.0, p.header_bg); + // The brand mark, the tools (the Selection tool is active) and the Discord pill. + painter.rect_filled(Rect::from_min_size(bar.min + vec2(4.0, 3.0), vec2(7.0, 7.0)), 2.0, p.accent); + for i in 0..7 { + let c = if i == 0 { p.accent } else { p.icon }; + painter.rect_filled(Rect::from_min_size(bar.min + vec2(16.0 + i as f32 * 8.0, 4.0), vec2(5.0, 5.0)), 1.0, c); + } + painter.rect_filled( + Rect::from_min_max(pos2(bar.right() - 22.0, bar.top() + 3.0), pos2(bar.right() - 4.0, bar.bottom() - 3.0)), + 3.5, + egui::Color32::from_rgb(0x58, 0x65, 0xf2), + ); + let gap = 2.0; + let body = Rect::from_min_max(pos2(rect.left() + gap, bar.bottom() + gap), pos2(rect.right() - gap, rect.bottom() - gap)); + let right_w = (body.width() * 0.22).round(); + let timeline_h = (body.height() * 0.42).round(); + let left_w = (body.width() * 0.2).round(); + let upper = Rect::from_min_max(body.min, pos2(body.right() - right_w - gap, body.bottom() - timeline_h - gap)); + let panel = |r: Rect, title_w: f32| { + painter.rect_filled(r, 2.0, p.panel_bg); + painter.rect_filled(Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(title_w, 2.0)), 1.0, p.tab_text_active); + }; + // Project panel: rows with label swatches. + let project = Rect::from_min_max(upper.min, pos2(upper.left() + left_w, upper.bottom())); + panel(project, 14.0); + for (i, label) in [5usize, 5, 2, 2].into_iter().enumerate() { + let y = project.top() + 10.0 + i as f32 * 6.0; + painter.rect_filled(Rect::from_min_size(pos2(project.left() + 4.0, y), vec2(3.0, 3.0)), 0.5, p.labels.get(label).copied().unwrap_or(p.icon)); + painter.rect_filled(Rect::from_min_size(pos2(project.left() + 9.0, y + 0.5), vec2(project.width() * 0.45, 2.0)), 1.0, p.text_dim); + } + // Composition panel: the pasteboard and the comp frame with a title. + let comp = Rect::from_min_max(pos2(project.right() + gap, upper.top()), upper.max); + panel(comp, 22.0); + let paste = Rect::from_min_max(comp.min + vec2(2.0, 8.0), comp.max - vec2(2.0, 2.0)); + painter.rect_filled(paste, 0.0, p.pasteboard); + let frame_h = (paste.height() - 6.0).max(4.0); + let frame = Rect::from_center_size(paste.center(), vec2((frame_h * 16.0 / 9.0).min(paste.width() - 6.0), frame_h)); + painter.rect_filled(frame, 0.0, egui::Color32::from_rgb(0x14, 0x1c, 0x4a)); + painter.circle_stroke(frame.center(), frame.height() * 0.36, egui::Stroke::new(0.8, egui::Color32::from_rgb(0x4a, 0x7c, 0xe8))); + painter.rect_filled(Rect::from_center_size(frame.center(), vec2(frame.width() * 0.5, 2.5)), 1.0, egui::Color32::WHITE); + // Preview and Properties. + let column = Rect::from_min_max(pos2(body.right() - right_w, body.top()), body.max); + let preview = Rect::from_min_max(column.min, pos2(column.right(), column.top() + column.height() * 0.3)); + panel(preview, 12.0); + painter.rect_filled(Rect::from_min_size(preview.left_bottom() + vec2(4.0, -5.0), vec2(preview.width() * 0.25, 1.5)), 0.5, p.cache_green); + let props = Rect::from_min_max(pos2(column.left(), preview.bottom() + gap), column.max); + panel(props, 18.0); + for i in 0..5 { + let y = props.top() + 10.0 + i as f32 * 6.0; + painter.rect_filled(Rect::from_min_size(pos2(props.left() + 4.0, y), vec2(props.width() * 0.35, 2.0)), 1.0, p.text_dim); + painter.rect_filled(Rect::from_min_size(pos2(props.left() + props.width() * 0.55, y), vec2(props.width() * 0.25, 2.0)), 1.0, p.hot_text); + } + // Timeline: the layer list and the time graph, with coloured layer bars and the time marker. + let timeline = Rect::from_min_max(pos2(body.left(), upper.bottom() + gap), pos2(upper.right(), body.bottom())); + panel(timeline, 18.0); + painter.rect_filled(Rect::from_min_size(timeline.min + vec2(4.0, 8.0), vec2(14.0, 3.0)), 1.0, p.timecode); + let graph = Rect::from_min_max(pos2(timeline.left() + timeline.width() * 0.38, timeline.top() + 8.0), timeline.max - vec2(2.0, 2.0)); + painter.rect_filled(graph, 0.0, p.tl_bg); + painter.rect_filled(Rect::from_min_size(graph.min, vec2(graph.width(), 4.0)), 0.0, p.tl_ruler_bg); + for (i, label) in [1usize, 1, 8, 8, 1].into_iter().enumerate() { + let y = graph.top() + 7.0 + i as f32 * 5.5; + if y + 3.5 > graph.bottom() { + break; + } + let swatch = p.labels.get(label).copied().unwrap_or(p.icon); + let row_l = timeline.left() + 4.0; + if i == 1 { + painter.rect_filled(Rect::from_min_max(pos2(timeline.left(), y - 1.0), pos2(graph.left(), y + 4.5)), 0.0, p.row_selected); + } + painter.rect_filled(Rect::from_min_size(pos2(row_l, y), vec2(3.0, 3.0)), 0.5, swatch); + painter.rect_filled(Rect::from_min_size(pos2(row_l + 6.0, y + 0.5), vec2(graph.left() - row_l - 14.0, 2.0)), 1.0, p.text_dim); + let bar = Rect::from_min_max(pos2(graph.left() + 1.0, y), pos2(graph.right() - 1.0, y + 3.5)); + painter.rect_filled(bar, 1.0, swatch.gamma_multiply(0.8)); + } + let cti = graph.left() + graph.width() * 0.3; + painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti)); +} + +/// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the +/// chosen theme and a radio button per theme. +fn theme_card( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + t: &Tokens, + key: &str, + title: &str, + active: bool, + cur: &str, + options: &[(&str, &str)], + acts: &mut Vec, +) { + egui::Frame::new() + .fill(t.field_bg) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6.0) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.text)); + if active { + ui.label(RichText::new(tr("Active")).color(t.accent).small()); + } + }); + ui.add_space(4.0); + let kind = ThemeKind::from_name(cur).unwrap_or(if key.ends_with("lightTheme") { ThemeKind::Light } else { ThemeKind::Dark }); + theme_preview(ui, kind, ui.available_width()); + ui.add_space(4.0); + for (label, value) in options { + let r = ui.radio(cur == *value, tr(label)); + reg(app, &format!("settings.{key}.{value}"), &r, label); + if r.clicked() && cur != *value { + acts.push(Act::Set(key.into(), json!(value))); + } + } + }); +} + +/// Settings ▸ Appearance: Appearance Mode (Sync with System, Dark, Light) above the light and dark +/// theme cards. +fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &Tokens, acts: &mut Vec) { + let get = |key: &str| cur.pointer(&format!("/appearance/{key}")).and_then(Value::as_str).unwrap_or_default().to_string(); + let (mode, light, dark) = (get("appearanceMode"), get("lightTheme"), get("darkTheme")); + ui.horizontal(|ui| { + ui.add_sized(vec2(250.0, 18.0), egui::Label::new(format!("{}:", tr("Appearance Mode"))).truncate()); + let shown = APPEARANCE_MODES.iter().find(|(_, v)| *v == mode).map_or(mode.as_str(), |(l, _)| *l); + let r = egui::ComboBox::from_id_salt(("settings", "appearance.appearanceMode")) + .selected_text(tr(shown)) + .width(220.0) + .show_ui(ui, |ui| { + for (l, v) in APPEARANCE_MODES { + if ui.selectable_label(*v == mode, tr(l)).clicked() { + acts.push(Act::Set("appearance.appearanceMode".into(), json!(v))); + } + } + }) + .response; + reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode"); + }); + ui.add_space(6.0); + let system_light = app.system_light(ui.ctx()); + let light_active = mode == "light" || (mode == "auto" && system_light == Some(true)); + ui.columns(2, |cols| { + let [l, d] = cols else { return }; + theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts); + theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts); + }); + ui.add_space(6.0); +} + /// Bytes as "12.3 MB". pub fn human_bytes(b: u64) -> String { let units = ["bytes", "KB", "MB", "GB", "TB"]; @@ -449,7 +613,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.painter().text( r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, - p.title, + tr(p.title), Tokens::ui(12.5), if sel { Color32::WHITE } else { t.text }, ); @@ -466,7 +630,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // dialog scrolled sideways a little (#284). ui.set_width(ui.available_width()); let p = &all[idx]; - ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active)); + ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); ui.add_space(6.0); egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| { page_ui(app, ui, p, &cur, t, &mut acts); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 1d61ae4c..7df3439b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -14,6 +14,7 @@ use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; +use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -3280,7 +3281,7 @@ fn text_anim_popups( let mut opts: Vec = Vec::new(); let mut acts: Vec<(String, serde_json::Value)> = Vec::new(); if key == "animate" { - opts.push(if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }.into()); + opts.push(tr(if per_char { "Disable Per-character 3D" } else { "Enable Per-character 3D" }).to_string()); acts.push(("layer.enablePerChar3D".into(), json!({"layer": layer.id.0, "enabled": !per_char}))); opts.push("-".into()); acts.push((String::new(), json!(null))); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9bc70cb1..9adf007f 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -4,9 +4,10 @@ //! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and //! camera/light wireframes. +use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{Comp, Layer, LayerId}; +use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; @@ -667,16 +668,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let frame = comp.frame_rate.frame_at(time); let key = app.frame_key(cid, frame, scale); app.request_frame_urgent(cid, frame, scale); - if let Some(img) = app.frames.get(&key) { - let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key); + // The frame on screen. While a drag or a property scrub is rendering, the exact key changes + // with every edit and its render is still in flight; the newest completed revision of the + // same frame keeps the picture following the pointer instead of freezing until it stops. + if let Some((k, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, s)| s)) { + let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k); // A CPU frame goes up again when the magnification calls for another factor. let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64); let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify); if stale || refit { - show_frame(app, &ctx, key, img, minify); - } else if let Some((_, k)) = app.viewer_shown.as_mut() { + show_frame(app, &ctx, k, img, minify); + } else if let Some((_, s)) = app.viewer_shown.as_mut() { // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. - k.revision = key.revision; + s.revision = k.revision; } } if app.ui.viewer.transparency_grid { @@ -1173,7 +1177,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { anchor_only: mods.alt, }) }), - t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + // From where the button went down: by the time the pointer has moved far enough to + // count as a drag it has already left the press point, and the shape is drawn there. + t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }), t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => { // Off the pins: Alt or a press outside the art drags a marquee that selects pins. (mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press }) @@ -1380,10 +1386,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - Gesture::Create { start, .. } => { - let a = map.to_screen(start); - let r = Rect::from_two_pos(a, pos); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); + Gesture::Create { tool, start } => { + if let Some(d) = shape_drag(app, tool, start, cpt, mods) { + draw_shape_ghost(&painter, &map, app, &d, &t); + } } Gesture::Vertices { start, src, applied, inv, layer } => { // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). @@ -1522,7 +1528,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin), Gesture::Create { tool, start } => { let end = map.to_comp(pos); - create_shape(app, tool, start, end, mods.shift); + create_shape(app, tool, start, end, mods); } Gesture::Overlay(ov::Drag::Roi { start, keep }) => { let c = map.to_comp(pos); @@ -1896,38 +1902,159 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg Some(hit) } -fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) { - let mut w = (b[0] - a[0]).abs(); - let mut h = (b[1] - a[1]).abs(); - if w < 2.0 && h < 2.0 { - return; +/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new +/// shape layer. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ShapeTarget { + Mask, + IntoLayer, + NewLayer, +} + +impl ShapeTarget { + /// What the drag will make and where, named the way the ghost captions it. + fn label(self, layer_name: &str) -> String { + match self { + ShapeTarget::Mask => format!("Mask on “{layer_name}”"), + ShapeTarget::IntoLayer => format!("Into “{layer_name}”"), + ShapeTarget::NewLayer => "New shape layer".to_string(), + } } - if square { - let m = w.max(h); - w = m; - h = m; - } - let cx = a[0].min(b[0]) + w / 2.0; - let cy = a[1].min(b[1]) + h / 2.0; - let kind = match tool { +} + +/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it +/// will land. +struct ShapeDrag { + /// The `shape.newShape` / `layer.addMask` kind for the tool. + kind: &'static str, + /// Comp-space centre and size, what the drag will make. + centre: [f64; 2], + size: [f64; 2], + target: ShapeTarget, + /// The selected layer's name and the index its next mask would take, for the ghost's caption + /// and colour. + layer_name: String, + mask_index: usize, +} + +/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. +fn shape_kind(tool: Tool) -> &'static str { + match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", Tool::Ellipse => "ellipse", Tool::Polygon => "polygon", _ => "star", - }; + } +} + +/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make +/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and +/// [`create_shape`] both go through this, so the drag always shows what it will commit. +fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let comp = app.session.active_comp()?; + let selected = app.session.state.selected_layers.first().copied(); + let sel = selected.and_then(|id| comp.layer(id)); // A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates - // Mask: a mask on it instead. - let creates_mask = app.session.state.shape_tool.creates_mask; - let sel = app.session.state.selected_layers.first().copied(); - let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); - if let Some(l) = sel_layer - && l.source.is_av() - && (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape)) - { - // Mask in layer space: invert the layer transform. - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { + // Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked + // shape layer, else a new one. + let target = match sel { + Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)), + _ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) { + Some(l) => (ShapeTarget::IntoLayer, Some(l)), + None => (ShapeTarget::NewLayer, None), + }, + }; + let (target, layer) = target; + let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 }; + let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); + // Ctrl/Cmd draws from the press point as the centre, as in After Effects. + if mods.command { + w *= 2.0; + h *= 2.0; + } + if w < 2.0 && h < 2.0 { + return None; + } + if mods.shift { + let m = w.max(h); + w = m; + h = m; + } + Some(ShapeDrag { + kind: shape_kind(tool), + centre: if mods.command { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] }, + size: [w, h], + target, + layer_name: layer.map_or_else(String::new, |l| l.name.clone()), + mask_index, + }) +} + +/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost. +fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 { + let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha) +} + +/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and +/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes. +fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) { + // The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it. + let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return }; + let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map); + if pts.first() == pts.last() { + pts.pop(); + } + if pts.len() < 3 { + return; + } + let tool = &app.session.state.shape_tool; + let (fill, line) = match d.target { + ShapeTarget::Mask => { + let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index); + (Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b)) + } + _ => { + let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0; + ( + if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) }, + if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent }, + ) + } + }; + let Some(bounds) = bounds_of(&pts) else { return }; + // A screen-width outline reads at every zoom, where a comp-pixel hairline does not. + painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() })); + // Caption it with where the shape lands: a mask, a group in the selected shape layer or a + // new layer. + let font = Tokens::ui(11.0); + let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line); + let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size()); + painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150)); + painter.galley(caption.min, galley, line); +} + +/// The bounds of points on screen, or `None` for an empty set. +fn bounds_of(pts: &[Pos2]) -> Option { + let first = *pts.first()?; + let mut r = Rect::from_min_max(first, first); + for p in pts.iter().skip(1) { + r.extend_with(*p); + } + Some(r) +} + +fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) { + let Some(d) = shape_drag(app, tool, a, b, mods) else { return }; + let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1])); + // A mask on the selected layer, as the ghost showed. + if d.target == ShapeTarget::Mask { + // In layer space: invert the layer transform. + let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); + if let Some(l) = sel_layer + && let Some(comp) = app.session.active_comp_arc() + { let ectx = EvalCtx { project: &app.session.project, comp_id: app.session.active_comp_id().unwrap_or_default(), @@ -1943,8 +2070,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { app.ui.status = e.to_string(); } - return; } + return; } // Into the selected shape layer's Contents as a new group (as in After Effects), else a new // shape layer; painted with the Tools bar's Fill and Stroke. diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 51a7d1e5..0038f192 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { +/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point +/// repeated for a closed path. +pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec { let k = effectcraft_engine::render::kurbo_path(sp); let mut pts: Vec = vec![]; kurbo::flatten(k.iter(), 0.5, |el| match el { diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 8d41eb73..06679783 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -486,7 +486,9 @@ fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, it ui.separator(); continue; } - let r = ui.selectable_label(*on, label.as_str()); + // The item is the English source: the drawn text is looked up in the catalog, while + // the automation label stays English for scripts and tests. + let r = ui.selectable_label(*on, crate::i18n::tr(label)); app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); if r.clicked() { chosen = Some(i); @@ -520,10 +522,10 @@ pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { } else { "Quarter" }; - format!("({r})") + format!("({})", crate::i18n::tr(r)) } - Resolution::Custom(n) => format!("Custom ({n})"), - r => r.label().to_string(), + Resolution::Custom(n) => format!("{} ({n})", crate::i18n::tr("Custom")), + r => crate::i18n::tr(r.label()).to_string(), } } @@ -602,7 +604,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) { let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip); + // The tip is the English source: the tooltip is looked up in the catalog, while the + // automation label stays English for scripts and tests. + let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip)); auto.add(&format!("viewer.{id}"), r, tip); *x += 24.0; (resp.clicked(), r) diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index c0c4a454..f23fd511 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -101,7 +101,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { let frame = c.frame_rate.frame_at(app.session.time_of(cid)); app.request_frame(cid, frame, scale); let key = app.frame_key(cid, frame, scale); - if let Some(img) = app.frames.get(&key) { + if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) { set_texture(app, &ctx, id, key, img); } match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) { @@ -127,7 +127,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { /// Keep the frame viewer `id` shows in its texture. fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) { - if app.passive_tex.get(&id).is_some_and(|(k, _)| *k == key) { + if let Some((k, _)) = app.passive_tex.get_mut(&id) + && *k == key + { + // An undo or an edit elsewhere can reuse this content at a newer revision. Keep that + // revision as the lower bound for subsequent intermediate edit frames. + k.revision = key.revision; return; } let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 8627c9c6..ed497dc4 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -4,48 +4,107 @@ //! output (a second window showing the composition frame, Mercury Transmit-style). use egui::{Color32, Rect}; -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; use crate::EffectcraftApp; use crate::frames::FrameKey; /// Run once per frame, before the panels draw. pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { - dropped_files(app, ctx); memory_watch(app, ctx); home_on_open(app, ctx); video_preview(app, ctx); } -/// Files dropped on the window are imported (layered files as Default Drag Import As says) in -/// the background, with the Importing card showing what is read (#270). Dropped on the -/// Composition viewer, they also become layers centred there, as in After Effects (#85), when the -/// platform reports where the pointer is during file drags. -fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { - let paths: Vec = - ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect()); - if paths.is_empty() { +/// Import native file drops through the same command as the file picker. Run after the dock +/// layout to use the current viewer rectangle for drops that also add layers. +#[cfg(not(target_arch = "wasm32"))] +pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { + use egui::{Align2, Stroke, StrokeKind, vec2}; + + let ctx = ui.ctx().clone(); + let hovered = ctx.input(|i| i.raw.hovered_files.len()); + if hovered != 0 { + let label = + if hovered == 1 { crate::i18n::tr("Drop file to import").to_string() } else { crate::i18n::tr_args("Drop {} files to import", &[&hovered]) }; + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("native-file-drop"))); + painter.rect_stroke(bounds.shrink(8.0), 6.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + let hint = Rect::from_center_size(bounds.center(), vec2(300.0, 46.0)); + painter.rect_filled(hint, 6.0, app.tokens.panel_bg); + painter.text(hint.center(), Align2::CENTER_CENTER, &label, crate::theme::Tokens::medium(14.0), app.tokens.text); + app.auto.add("file.dropTarget", hint, &label); + } + + let (paths, skipped) = ctx.input(|i| { + let mut paths = Vec::new(); + let mut skipped = 0; + for file in &i.raw.dropped_files { + if file.path().is_absolute() { + paths.push(file.path().to_string_lossy().into_owned()); + } else { + skipped += 1; + } + } + (paths, skipped) + }); + + if paths.is_empty() && skipped == 0 { return; } + + let mut errors = Vec::new(); + if skipped != 0 { + errors.push(crate::i18n::tr_args("Skipped {} dropped file(s) without a local path. Use File ▸ Import ▸ File…", &[&skipped])); + } + let (proj, rest): (Vec, Vec) = paths.into_iter().partition(|p| { let l = p.to_ascii_lowercase(); l.ends_with(".ecproj") || l.ends_with(".ecprojx") }); - if let Some(p) = proj.first() - && let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p})) - { - app.ui.status = e; + + if proj.len() > 1 { + errors.push(crate::i18n::tr_args("Only one project can be opened per drop; skipped {} more", &[&proj.len().saturating_sub(1)])); } + + if let Some(p) = proj.first() + && let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) + { + errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e])); + } + if !rest.is_empty() { - let viewer = app.auto.find("viewer.area").map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); - let at = - ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(ctx, p)); - let params = - json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true}); - if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { - app.ui.status = e; + let viewer = app + .auto + .elements + .iter() + .find(|e| e.id == "viewer.area") + .map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); + + let at = if proj.is_empty() { + ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p)) + } else { + None + }; + + let params = json!({ + "paths": rest, + "drag": true, + "importAs": app.session.prefs.drag_import_as(), + "addToComp": at.is_some(), + "position": at, + "background": true + }); + + if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) { + errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e])); } } + + if !errors.is_empty() { + app.ui.status = errors.join("; "); + } } /// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each @@ -122,12 +181,16 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { }, (_, _, prev) => prev.map(|p| p.1), }; + let title = format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default()); + let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]); + if v.device == "fullscreen" { builder = builder.with_fullscreen(true); } + let mut closed = false; ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| { @@ -143,6 +206,7 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { closed = true; } }); + if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) { app.ui.status = e; } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 74ab0b7e..a37bead6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -26,6 +26,19 @@ impl ThemeKind { _ => None, } } + + /// The settings value (`appearance.theme`, `appearance.darkTheme`, `appearance.lightTheme`). + pub fn name(self) -> &'static str { + match self { + ThemeKind::Dark => "dark", + ThemeKind::Darker => "darker", + ThemeKind::Light => "light", + } + } + + pub fn is_light(self) -> bool { + self == ThemeKind::Light + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -213,8 +226,9 @@ impl Tokens { } /// Theme tokens for the current settings: theme, UI brightness and label colours. - pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens { - let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default(); + /// The tokens for theme `kind` (resolved from the appearance mode) with the rest of the + /// Appearance settings: brightness, label colours, gradients. + pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs, kind: ThemeKind) -> Tokens { let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32); for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() { let [r, g, b] = p.label_rgb(*l); @@ -387,7 +401,11 @@ fn is_chinese(language: &str) -> bool { } pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { - let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() }; + let light = t.kind.is_light(); + // egui keeps one style per light/dark theme and by default picks it by the system appearance; + // pin it to ours so a fixed Dark or Light mode isn't swapped for egui's defaults. + ctx.set_theme(if light { egui::ThemePreference::Light } else { egui::ThemePreference::Dark }); + let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; v.extreme_bg_color = t.field_bg; diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index 01843de7..02fefb14 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -119,7 +119,13 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() { #[test] fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { let fake = Fake::default(); - let (app, cid) = setup(true, &fake); + let (mut app, cid) = setup(true, &fake); + // Playback requests at most twice as many frames ahead per step as there are render threads. + // Four times that many frames take at least four steps to cache, so frames are shown silently + // first however fast the renderer is (with a fixed 2 s comp, a machine with many cores cached + // all of it before the first step and the sound started at once). + let ahead = (app.frames.parallelism() * 2).max(4); + app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); // A little under one frame of input time per step. 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); diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index db891256..9738fef2 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -197,6 +197,9 @@ impl effectcraft_engine::Importer for Gated { while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until { std::thread::sleep(std::time::Duration::from_millis(1)); } + if path.ends_with(".unsupported") { + return Err("unsupported media format".into()); + } Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, ..Default::default() }) } } @@ -221,6 +224,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { let allow = Arc::new(AtomicUsize::new(1)); h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })); let file = |n: &str| std::env::temp_dir().join(n); + // External files import even when dropped over the Timeline rather than the Project panel. + h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center())); + h.step(); for n in ["a.png", "b.png", "c.png"] { h.input_mut().dropped_files.push(Arc::new(Dropped(file(n)))); } @@ -242,7 +248,9 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { assert!(h.state().auto.find(&format!("project.item.{last}")).is_some(), "and in view in the Project panel"); assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 3 items")); - // The same file again: imported again, and that one is selected. + // The same file again over the Project panel: imported again and selected. + h.event(Event::PointerMoved(rect(&h, "panel.Project").center())); + h.step(); h.input_mut().dropped_files.push(Arc::new(Dropped(file("a.png")))); h.run_steps(2); step_until(&mut h, |h| items(h) == 4); @@ -253,6 +261,57 @@ fn dropped_files_show_import_progress_then_the_items_they_made() { assert_eq!(h.state().auto.find("toast").map(|e| e.label.as_str()), Some("Imported 1 item")); } +#[test] +fn native_file_hover_shows_drop_target_hint() { + let (mut h, _) = harness(); + for name in ["one.mp4", "two.mov"] { + h.input_mut().hovered_files.push(egui::HoveredFile { path: Some(std::env::temp_dir().join(name)), mime: String::new() }); + } + h.step(); + assert_eq!(h.state().auto.find("file.dropTarget").map(|e| e.label.as_str()), Some("Drop 2 files to import")); +} + +#[test] +fn dropped_file_without_local_path_explains_the_fallback() { + let (mut h, _) = harness(); + h.input_mut().dropped_files.push(Arc::new(Dropped("relative.mp4".into()))); + h.run_steps(2); + assert!(h.state().ui.status.contains("without a local path"), "{}", h.state().ui.status); + assert!(h.state().ui.status.contains("File ▸ Import")); +} + +#[test] +fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + h.event(Event::PointerMoved(to)); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("viewer-drop.mp4")))); + h.run_steps(2); + step_until(&mut h, |h| h.state().session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "viewer-drop.mp4"))); + let comp = h.state().session.active_comp().unwrap(); + let layer = comp.layers.iter().find(|l| l.name == "viewer-drop.mp4").unwrap(); + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); +} + +#[test] +fn an_unsupported_drop_reports_the_error_and_keeps_valid_files() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + for name in ["valid.mp4", "bad.unsupported"] { + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join(name)))); + } + h.run_steps(2); + step_until(&mut h, |h| h.state().session.job_log.iter().any(|j| j.kind == "import" && j.message.contains("unsupported media format"))); + let job = h.state().session.job_log.iter().find(|j| j.kind == "import").unwrap(); + assert!(job.message.contains("bad.unsupported: unsupported media format"), "{}", job.message); + assert_eq!(job.result["errors"].as_array().map(Vec::len), Some(1)); + assert!(h.state().session.project.items.values().any(|i| i.name == "valid.mp4")); +} + /// The automation id of the Importing card on screen. fn card_id(h: &Harness<'_, EffectcraftApp>) -> String { h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default() diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 9347ebe2..f6137f5f 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -424,3 +424,153 @@ fn extractor_offers_the_exr_layers_and_channels() { assert_eq!(values(&h)[0], "depth.Z"); let _ = std::fs::remove_file(path); } + +#[test] +fn point_picker_handle_follows_hover_then_commits_once_across_effects() { + for (effect, parameter) in [("ec.control.point", "point"), ("ec.blur.ccradialfast", "center"), ("ec.generate.gradientramp", "start")] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width":640,"height":360,"duration":1})).unwrap(); + let lid = s.execute("layer.newSolid", json!({"width":200,"height":100,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer":lid,"path":"transform/scale","value":[200,200]})).unwrap(); + let fx = s.execute("effect.apply", json!({"layer":lid,"effect":effect})).unwrap()["effects"][0].as_u64().unwrap(); + let point = layer(&s, lid).effects().unwrap().find_group(fx).unwrap().get(parameter).unwrap().uid; + s.execute("prop.set", json!({"layer":lid,"prop":point,"value":[100,50]})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.ui.viewer.zoom = Some(0.85); + app.ui.viewer.pan = [25.0, -10.0]; + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let id = format!("viewer.effectPoint.{point}"); + let original = rect_of(&h, &id).center(); + let pick_button = rect_of(&h, &format!("effectControls.prop.{point}.crosshair")).center(); + click(&mut h, pick_button); + assert!(h.state().ui.fx_pick.is_some(), "{effect}: crosshair arms the picker"); + assert_eq!(rect_of(&h, &id).center(), original, "the handle remains visible before entering the viewer"); + let undo = h.state().session.history.undo.len(); + for comp in [[350.0, 200.0], [370.0, 220.0]] { + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, comp).unwrap(); + hover(&mut h, pos); + assert!(rect_of(&h, &id).center().distance(pos) < 0.1, "{effect}: handle must follow the pointer before clicking"); + assert_eq!(layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0], "hover is only a preview"); + assert_eq!(h.state().session.history.undo.len(), undo, "hover must not add undo entries"); + } + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [370.0, 220.0]).unwrap(); + click(&mut h, pos); + assert!(h.state().ui.fx_pick.is_none()); + let after = layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2(); + assert!((after[0] - 125.0).abs() < 1.0 && (after[1] - 70.0).abs() < 1.0, "{effect}: click uses layer space: {after:?}"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one click is one edit"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0]); + assert_eq!(layer(&h.state().session, lid).props.prop("transform/position").unwrap().value.as_vec2(), [320.0, 180.0]); + } +} + +#[test] +fn point_picker_preview_cancels_and_works_with_layer_controls_hidden() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let value = layer(&h.state().session, x.small).props.find(x.point).unwrap().value.clone(); + let undo = h.state().session.history.undo.len(); + h.state_mut().ui.viewer.show_layer_controls = false; + let pick_button = rect_of(&h, &format!("effectControls.prop.{}.crosshair", x.point)).center(); + click(&mut h, pick_button); + let id = format!("viewer.effectPoint.{}", x.point); + let original = rect_of(&h, &id).center(); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [370.0, 220.0]).unwrap(); + hover(&mut h, pos); + assert!(rect_of(&h, &id).center().distance(pos) < 0.1, "explicit picks show a handle even with layer controls hidden"); + hover(&mut h, pick_button); + assert_eq!(rect_of(&h, &id).center(), original, "leaving the viewer restores the current position"); + hover(&mut h, pos); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none()); + assert!(!ids(&h).contains(&id), "cancel removes the temporary handle"); + assert_eq!(layer(&h.state().session, x.small).props.find(x.point).unwrap().value, value); + assert_eq!(h.state().session.history.undo.len(), undo, "cancel leaves the project unchanged"); +} + +#[test] +fn radial_center_stays_draggable_when_its_property_is_selected() { + let (mut app, x) = app(); + app.session.execute("effect.apply", json!({"layer":x.small,"effect":"ec.blur.ccradialfast"})).unwrap(); + let l = layer(&app.session, x.small); + let effect = l.effects().unwrap().groups().find(|g| g.match_id == "ec.blur.ccradialfast").unwrap(); + let point = effect.get("center").unwrap().uid; + app.session.state.selected_props = vec![(LayerId(x.small), point)]; + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let id = format!("viewer.effectPoint.{point}"); + assert!(ids(&h).contains(&id), "selected point must keep its handle"); + let from = rect_of(&h, &id).center(); + let to = from + egui::vec2(40.0, 20.0); + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=5 { + h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 5.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + let after = layer(&h.state().session, x.small).props.find(point).unwrap().value.as_vec2(); + assert!(after[0] > 100.0 && after[1] > 50.0, "drag updates Center: {after:?}"); + let position = layer(&h.state().session, x.small).props.prop("transform/position").unwrap().value.as_vec3(); + assert_eq!(position[..2], [320.0, 180.0], "dragging the effect must not move the layer"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&h.state().session, x.small).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0], "one Undo reverses the gesture"); +} + +#[test] +fn clicking_an_effect_parameter_activates_its_header_and_handles() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let name = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center(); + click(&mut h, name); + assert_eq!(h.state().session.state.selected_props, vec![(LayerId(x.small), x.point)]); + assert!(ids(&h).contains(&format!("viewer.effectPoint.{}", x.point)), "clicking the parameter activates the owner effect"); +} + +#[test] +fn non_point_parameters_activate_handles_across_effect_families() { + for (effect, parameter, points) in [ + ("ec.distort.cclens", "size", vec!["center"]), + ("ec.generate.gradientramp", "startColor", vec!["start", "end"]), + ("ec.generate.beam", "length", vec!["startPoint", "endPoint"]), + ] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width":320,"height":180,"duration":1})).unwrap(); + let lid = s.execute("layer.newSolid", json!({"color":"#406080"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.execute("effect.apply", json!({"layer":lid,"effect":effect})).unwrap()["effects"][0].as_u64().unwrap(); + let l = layer(&s, lid); + let g = l.effects().unwrap().find_group(uid).unwrap(); + let prop = g.prop(parameter).unwrap().uid; + let point_ids: Vec<_> = points.iter().map(|name| g.prop(name).unwrap().uid).collect(); + s.state.selected_props.clear(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let name = rect_of(&h, &format!("effectControls.prop.{prop}.name")).center(); + click(&mut h, name); + assert_eq!(h.state().session.state.selected_props, vec![(LayerId(lid), prop)], "{effect}"); + for point in point_ids { + assert!(ids(&h).contains(&format!("viewer.effectPoint.{point}")), "{effect}: all its point handles activate"); + } + } +} + +#[test] +fn custom_effect_editors_activate_their_owner() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let graph = rect_of(&h, &format!("effectControls.effect.{}.curves.graph", x.curves)).center(); + click(&mut h, graph); + assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves))); +} diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 4aca21a5..92d18a44 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -19,20 +19,45 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`), +/// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on +/// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the +/// viewer would stay on the CPU and the comparison would never run. +fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let fallback = c.native_adapter_selector.take(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name; + match (adapters.iter().find(named), &fallback) { + (Some(a), _) => Ok(a.clone()), + (None, Some(f)) => f(adapters, surface), + (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()), + } + })); + } + setup +} + #[test] fn viewer_draws_gpu_frames_that_match_the_cpu() { let Some(probe) = effectcraft_gpu::Gpu::headless() else { eprintln!("no GPU adapter: skipping"); return; }; - eprintln!("headless compositor adapter: {}", effectcraft_engine::render::Accelerator::name(&probe)); + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + eprintln!("headless compositor adapter: {adapter}"); // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. - let gl = effectcraft_engine::render::Accelerator::name(&probe).ends_with("(Gl)"); + let gl = adapter.ends_with("(Gl)"); drop(probe); let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); s.execute("time.set", json!({"time": 2.0})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s)); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .wgpu_setup(same_adapter(adapter)) + .build_eframe(|_| EffectcraftApp::new(s)); settle(&mut h); if gl && h.state().gpu_adapter().is_none() { eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); diff --git a/crates/ui-egui/tests/ui_live_preview.rs b/crates/ui-egui/tests/ui_live_preview.rs new file mode 100644 index 00000000..e72856bf --- /dev/null +++ b/crates/ui-egui/tests/ui_live_preview.rs @@ -0,0 +1,158 @@ +//! Completed edits must reach the viewer even when a newer edit is still rendering. + +use std::sync::{Arc, Mutex}; + +use effectcraft_engine::{ + Session, + project::LayerId, + render::{NoFootage, Renderer}, +}; +use effectcraft_ui_egui::{ + EffectcraftApp, + frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, to_color_image}, +}; +use egui_kittest::Harness; +use serde_json::json; + +/// Hold completions explicitly: input/render ordering is deterministic, without timing sleeps. +#[derive(Default)] +struct HeldRenderer(Mutex>); + +impl RemoteFrames for HeldRenderer { + fn slots(&self) -> usize { + 1 + } + fn start(&self, job: RemoteJob, done: RemoteDone) { + self.0.lock().unwrap().push((job, done)); + } +} + +impl HeldRenderer { + fn finish(&self) -> egui::ColorImage { + let (job, done) = self.0.lock().unwrap().remove(0); + let image = Renderer::new(&job.project, &NoFootage, job.opts).comp_frame(job.comp, job.t); + let pixels = to_color_image(&image); + done(Ok(RemoteFrame { width: image.width, height: image.height, rgba: pixels.pixels.iter().flat_map(|c| c.to_array()).collect(), ms: 20.0 })); + pixels + } +} + +#[test] +fn opacity_edits_show_completed_intermediate_frames_before_the_drag_stops() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name":"Live opacity", "width":64, "height":36, "duration":1})).unwrap(); + let layer = session.execute("layer.newSolid", json!({"color":"#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let renderer = Arc::new(HeldRenderer::default()); + let mut app = EffectcraftApp::new(session); + app.frames.set_remote(Some(renderer.clone())); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.016).build_eframe(|_| app); + h.run_steps(3); + assert_eq!(h.state().frames.dispatch_remote(), 1); + renderer.finish(); + h.step(); + let alpha = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().unwrap().pixels[32 + 18 * 64].a(); + assert_eq!(alpha(&mut h), 255); + for opacity in [80, 60, 40, 20] { + h.state_mut().session.execute("prop.set", json!({"layer":layer, "path":"transform/opacity", "value":opacity, "merge":"scrub"})).unwrap(); + h.step(); + assert_eq!(h.state().frames.dispatch_remote(), 1); + // Another input arrives before that frame completes, as in a fast slider drag. + h.state_mut().session.execute("prop.set", json!({"layer":layer, "path":"transform/opacity", "value":opacity - 1, "merge":"scrub"})).unwrap(); + renderer.finish(); + h.step(); + assert_eq!(alpha(&mut h), (opacity as f32 * 2.55).round() as u8, "completed {opacity}% opacity must display while the next edit renders"); + } + // Releasing the slider still converges to the exact final state. + assert_eq!(h.state().frames.dispatch_remote(), 1); + renderer.finish(); + h.step(); + assert_eq!(alpha(&mut h), (19.0_f32 * 2.55).round() as u8); + let series = h.state().shown_series(h.state().session.active_comp_id().unwrap()); + assert!(h.state().frames.is_cached(&series)); +} + +#[test] +fn position_anchor_and_effect_edits_present_intermediate_pixels() { + for (effect, path, first, second) in [ + (None, "transform/position", json!([22, 18, 0]), json!([18, 18, 0])), + (None, "transform/anchor", json!([9, 8, 0]), json!([6, 8, 0])), + (Some("Gaussian Blur"), "blurriness", json!(3), json!(6)), + (Some("CC Radial Fast Blur"), "center", json!([4, 4]), json!([18, 12])), + ] { + let mut session = Session::default(); + session.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap(); + let layer = session.execute("layer.newSolid", json!({"color":"#ffffff","width":24,"height":16})).unwrap()["layer"].as_u64().unwrap(); + if effect == Some("CC Radial Fast Blur") { + // A uniform solid is invariant under a radial blur; give the point edit visible detail. + session.execute("effect.apply", json!({"layers":[layer],"effect":"Gradient Ramp"})).unwrap(); + } + let property = if let Some(effect) = effect { + session.execute("effect.apply", json!({"layers":[layer],"effect":effect})).unwrap(); + session.active_comp().unwrap().layer(LayerId(layer)).unwrap().effects().unwrap().groups().last().unwrap().get(path).unwrap().uid + } else { + session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop(path).unwrap().uid + }; + let renderer = Arc::new(HeldRenderer::default()); + let mut app = EffectcraftApp::new(session); + app.frames.set_remote(Some(renderer.clone())); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.016).build_eframe(|_| app); + h.run_steps(3); + h.state().frames.dispatch_remote(); + let initial = renderer.finish(); + h.step(); + h.state_mut().session.execute("prop.set", json!({"layer":layer,"prop":property,"value":first,"merge":"drag"})).unwrap(); + h.step(); + assert_eq!(h.state().frames.dispatch_remote(), 1); + h.state_mut().session.execute("prop.set", json!({"layer":layer,"prop":property,"value":second,"merge":"drag"})).unwrap(); + let intermediate = renderer.finish(); + assert!(initial.pixels != intermediate.pixels, "{effect:?} {path}: fixture must change rendered pixels"); + h.step(); + assert_eq!(h.state_mut().viewer_pixels().unwrap().pixels, intermediate.pixels, "{effect:?} {path}: display finished edit while next edit renders"); + assert_eq!(h.state().frames.dispatch_remote(), 1); + let final_image = renderer.finish(); + h.step(); + assert_eq!(h.state_mut().viewer_pixels().unwrap().pixels, final_image.pixels, "{effect:?} {path}: converge to exact final edit"); + } +} + +/// Manual throughput probe: no timing threshold in CI. Includes egui layout and native workers, +/// but no window compositor or GPU upload; it measures presentation progress during edits. +#[test] +#[ignore = "manual native-worker preview throughput probe"] +fn continuous_opacity_preview_probe() { + use std::time::{Duration, Instant}; + let mut session = Session::default(); + session.execute("comp.new", json!({"width":1920,"height":1080,"duration":1})).unwrap(); + let layer = session.execute("layer.newSolid", json!({"color":"#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(session)); + let ready = Instant::now(); + while h.state_mut().viewer_pixels().is_none() && ready.elapsed() < Duration::from_secs(5) { + h.step(); + std::thread::sleep(Duration::from_millis(5)); + } + let mut last = h.state_mut().viewer_pixels().expect("initial viewer frame"); + let start = Instant::now(); + let mut updates = 0; + let mut longest_hold = Duration::ZERO; + let mut last_update = start; + for i in 0..120 { + h.state_mut().session.execute("prop.set", json!({"layer":layer,"path":"transform/opacity","value":99-i%90,"merge":"scrub"})).unwrap(); + h.step(); + let now = h.state_mut().viewer_pixels().unwrap(); + if !Arc::ptr_eq(&now, &last) { + updates += 1; + longest_hold = longest_hold.max(last_update.elapsed()); + last_update = Instant::now(); + last = now; + } + let deadline = start + Duration::from_secs_f64((i + 1) as f64 / 60.0); + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + } + longest_hold = longest_hold.max(last_update.elapsed()); + println!( + "LIVE_PREVIEW_PROBE updates={updates}/120 elapsed_ms={:.1} longest_hold_ms={:.1} last_render_ms={:.3}", + start.elapsed().as_secs_f64() * 1000.0, + longest_hold.as_secs_f64() * 1000.0, + *h.state().frames.last_ms.lock().unwrap() + ); +} diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index 3d252d90..142a4fa3 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -120,3 +120,87 @@ fn enter_chooses_the_highlighted_entry() { } assert!(!shown(&h, "Quick Apply"), "and closed the menu"); } + +#[test] +fn relink_menu_uses_engine_availability_and_folder_picker_cancel_is_a_noop() { + use std::cell::Cell; + use std::rc::Rc; + + let mut app = EffectcraftApp::new(Session::default()); + let menu = effectcraft_ui_egui::menus::menu_items(&app); + let entry = menu.iter().find(|e| e.id == "file.relinkFootage").unwrap(); + assert_eq!(entry.path, ["File", "Dependencies"]); + assert_eq!(entry.enabled, app.session.is_enabled("file.relinkFootage")); + assert_eq!(entry.enabled, !cfg!(target_arch = "wasm32")); + let picked = Rc::new(Cell::new(false)); + let flag = picked.clone(); + app.hooks.pick_folder = Some(Box::new(move || { + flag.set(true); + None + })); + let revision = app.session.revision; + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.relinkFootage", json!({})).unwrap(); + assert!(picked.get()); + assert!(result.is_null()); + assert_eq!(app.session.revision, revision); + assert!(app.session.history.undo.is_empty()); +} + +#[test] +fn relink_is_reachable_through_the_dependencies_menu() { + use std::cell::Cell; + use std::rc::Rc; + + let mut h = harness(); + let picked = Rc::new(Cell::new(false)); + let flag = picked.clone(); + h.state_mut().hooks.pick_folder = Some(Box::new(move || { + flag.set(true); + None + })); + let revision = h.state().session.revision; + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + let dependencies = h.query_by_label_contains("Dependencies").unwrap().rect(); + h.input_mut().events.push(Event::PointerMoved(dependencies.center())); + h.run_steps(8); + let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect(); + if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") { + h.render().unwrap().save(path).unwrap(); + } + click_at(&mut h, relink.center()); + assert!(picked.get()); + assert_eq!(h.state().session.revision, revision); +} + +#[test] +fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { + use effectcraft_engine::project::{ItemId, ItemKind}; + use effectcraft_engine::render::RenderOpts; + use effectcraft_engine::time::Tick; + + let dir = std::env::temp_dir().join(format!("ec-ui-relink-{}", std::process::id())); + std::fs::create_dir_all(dir.join("old")).unwrap(); + std::fs::create_dir_all(dir.join("new/deep")).unwrap(); + let old = dir.join("old/plate.png"); + image::RgbaImage::from_pixel(32, 32, image::Rgba([230, 40, 80, 255])).save(&old).unwrap(); + let mut s = effectcraft_host::session(); + let item = s.execute("file.import", json!({"paths": [old], "sequence": false})).unwrap()["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Relink", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let expected = s.render(cid, Tick::ZERO, RenderOpts::default()); + let moved = dir.join("new/deep/plate.png"); + std::fs::rename(&old, &moved).unwrap(); + s.execute("footage.check", json!({"wait": true})).unwrap(); + let ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") }; + assert!(f.missing); + let missing_content = effectcraft_ui_egui::frames::comp_content(&s.project, cid); + let missing = s.render(cid, Tick::ZERO, RenderOpts::default()); + assert_ne!(missing.data, expected.data); + let r = s.execute("file.relinkFootage", json!({"folder": dir.join("new"), "wait": true})).unwrap(); + assert_eq!(r["relinked"], 1); + assert_ne!(effectcraft_ui_egui::frames::comp_content(&s.project, cid), missing_content); + assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/ui_project_delete.rs index 9963727d..f217c29d 100644 --- a/crates/ui-egui/tests/ui_project_delete.rs +++ b/crates/ui-egui/tests/ui_project_delete.rs @@ -1,4 +1,5 @@ //! Project-panel deletion uses its selection, even when a Timeline layer is still selected. +//! Shift+Delete deletes it without asking (Delete Project Items Without Confirmation). use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, LayerId}; @@ -51,6 +52,31 @@ fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { h.run_steps(2); } +/// Shift+Delete as each platform delivers it: a key press (macOS, Linux), or on Windows a Cut +/// event with Shift held (the windowing layer turns Shift+Delete into Cut). +#[derive(Clone, Copy, Debug)] +enum ShiftDelete { + Key, + WindowsCut, +} + +fn shift_delete(h: &mut Harness<'_, EffectcraftApp>, how: ShiftDelete) { + match how { + ShiftDelete::Key => key(h, Key::Delete, Modifiers::SHIFT), + // Shift down, Cut and Shift up all in one frame. + ShiftDelete::WindowsCut => frame(h, &[Event::ModifiersChanged(Modifiers::SHIFT), Event::Cut, Event::ModifiersChanged(Modifiers::NONE)]), + } +} + +/// Queue `events` together and run them as one frame. +fn frame(h: &mut Harness<'_, EffectcraftApp>, events: &[Event]) { + h.input_mut().events.extend(events.iter().cloned()); + h.step(); + h.run_steps(2); +} + +const SHIFT_DELETE: [ShiftDelete; 2] = [ShiftDelete::Key, ShiftDelete::WindowsCut]; + /// Items that compositions use ask first, as in After Effects: answer Delete when asked. /// Returns whether it asked. fn confirm_if_asked(h: &mut Harness<'_, EffectcraftApp>) -> bool { @@ -138,12 +164,22 @@ fn typing_dialogs_and_modified_keys_do_not_delete_project_items() { let item = items[0]; click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE); let project = h.state().session.project.clone(); - for delete_key in [Key::Delete, Key::Backspace] { - key(&mut h, delete_key, Modifiers::SHIFT); - assert_eq!(h.state().session.project, project); + // Only plain Delete/Backspace and Shift+Delete delete Project items. + for (k, m) in [ + (Key::Backspace, Modifiers::SHIFT), + (Key::Delete, Modifiers::ALT), + (Key::Delete, Modifiers::COMMAND), + (Key::Delete, Modifiers::SHIFT | Modifiers::COMMAND), + (Key::Delete, Modifiers::SHIFT | Modifiers::ALT), + ] { + key(&mut h, k, m); + assert_eq!(h.state().session.project, project, "{m:?}+{k:?}"); } h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); key(&mut h, Key::Delete, Modifiers::NONE); + for how in SHIFT_DELETE { + shift_delete(&mut h, how); + } assert_eq!(h.state().session.project, project); h.state_mut().dialog = None; h.run_steps(2); @@ -154,6 +190,10 @@ fn typing_dialogs_and_modified_keys_do_not_delete_project_items() { key(&mut h, delete_key, Modifiers::NONE); assert_eq!(h.state().session.project, project, "typing must not delete the item"); } + for how in SHIFT_DELETE { + shift_delete(&mut h, how); + assert_eq!(h.state().session.project, project, "typing must not delete the item ({how:?})"); + } } /// Deleting an item a composition uses asks first, as in After Effects, and says what goes with @@ -190,3 +230,137 @@ fn deleting_items_in_use_asks_first() { h.state_mut().session.execute("project.delete", json!({"items": [solid.0]})).unwrap(); assert!(h.state().session.project.item(solid).is_none()); } + +/// Shift+Delete deletes the selected item without asking, even one a composition uses, with the +/// layers that use it, in one undo step (Delete Project Items Without Confirmation). +#[test] +fn shift_delete_deletes_without_asking_and_undoes_once() { + for how in SHIFT_DELETE { + for target in 0..4 { + let (mut h, items, keep) = harness(); + let item = items[target]; + click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE); + let project = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None, "{how:?} item {target}: no prompt"); + assert!(h.state().session.project.item(item).is_none(), "{how:?} should delete project item {target}"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some(), "the unrelated Timeline layer stays"); + assert!(h.state().session.state.project_selection.is_empty()); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project, project, "one undo restores the item and its references"); + } + } +} + +#[test] +fn shift_delete_deletes_a_multi_selection_and_folder_contents_together() { + for how in SHIFT_DELETE { + let (mut h, items, keep) = harness(); + let (solid, folder) = (items[1], items[3]); + // The solid (used by a layer) inside the folder: deleting the folder takes both. + h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": folder.0})).unwrap(); + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + click(&mut h, &format!("project.item.{}.name", folder.0), Modifiers::COMMAND); + assert_eq!(h.state().session.state.project_selection, vec![items[0], folder]); + let project = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None, "{how:?}: no prompt"); + for gone in [items[0], solid, folder] { + assert!(h.state().session.project.item(gone).is_none(), "{how:?}: {gone:?} goes"); + } + assert!(h.state().session.project.item(items[2]).is_some(), "the unselected item stays"); + let uses = h.state().session.project.comps().flat_map(|(_, c)| c.layers.iter()).filter(|l| l.source.item() == Some(solid)).count(); + assert_eq!(uses, 0, "the layer using the solid goes with it"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some()); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project, project); + } +} + +/// Shift+Delete with nothing selected deletes nothing, and skipping the prompt once (Shift+Delete, +/// or answering Delete) doesn't skip it for the next Delete. +#[test] +fn shift_delete_and_answered_prompts_dont_skip_the_next_prompt() { + for how in SHIFT_DELETE { + let (mut h, items, keep) = harness(); + let solid = items[1]; + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + h.state_mut().session.execute("project.select", json!({"items": []})).unwrap(); + let project = h.state().session.project.clone(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None); + assert_eq!(h.state().session.project, project, "{how:?} with nothing selected deletes nothing"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some()); + + // Without asking once, then Delete on an item in use still asks. + click(&mut h, &format!("project.item.{}.name", items[2].0), Modifiers::NONE); + shift_delete(&mut h, how); + assert!(h.state().session.project.item(items[2]).is_none()); + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::DeleteItems), "{how:?}: Delete still asks after Shift+Delete"); + click(&mut h, "dialog.deleteItems.cancel", Modifiers::NONE); + assert!(h.state().session.project.item(solid).is_some()); + } + + // Answering Delete runs that deletion only: the next Delete asks again. + let (mut h, items, _) = harness(); + let solid = items[1]; + h.state_mut().session.execute("layer.newSolid", json!({"name": "Second solid", "color": "#806040"})).unwrap(); + let second = h.state().session.project.items.values().find(|i| i.name == "Second solid").unwrap().id; + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert!(confirm_if_asked(&mut h)); + assert!(h.state().session.project.item(solid).is_none()); + click(&mut h, &format!("project.item.{}.name", second.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::DeleteItems), "the answer was for the first deletion only"); +} + +/// Shift+Delete only deletes Project items with the Project panel focused. +#[test] +fn shift_delete_in_other_panels_keeps_project_items() { + for how in SHIFT_DELETE { + let (mut h, items, _) = harness(); + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + click(&mut h, "viewer.comp", Modifiers::NONE); + assert_eq!(h.state().ui.focused, PanelKind::Composition); + assert_eq!(h.state().session.state.project_selection, vec![items[0]]); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None); + assert!(items.iter().all(|i| h.state().session.project.item(*i).is_some()), "{how:?} in the Composition panel"); + } +} + +/// A Cut is read with the modifiers held where it is among the frame's events, not those held +/// after the frame: Shift released in the same frame is still Shift+Delete, and a Ctrl+X followed +/// in the same frame by Ctrl up and Shift down is not. +#[test] +fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { + use Event::{Cut, ModifiersChanged as M}; + let (shift, ctrl, none) = (Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::NONE); + // (held from the frame before, this frame's events, whether the solid in use goes) + let cases: [(Modifiers, Vec, bool); 5] = [ + (shift, vec![Cut, M(none)], true), + (ctrl, vec![Cut, M(none), M(shift)], false), + (none, vec![M(shift), Cut, M(none)], true), + (none, vec![M(ctrl), Cut, M(none), M(shift)], false), + (shift, vec![M(none), Cut, M(shift)], false), + ]; + for (n, (before, events, deletes)) in cases.into_iter().enumerate() { + let (mut h, items, _) = harness(); + let solid = items[1]; + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + frame(&mut h, &[M(before)]); + frame(&mut h, &events); + frame(&mut h, &[M(none)]); + assert_eq!(h.state().dialog, None, "case {n}: no prompt"); + assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/ui_settings_info.rs new file mode 100644 index 00000000..64f088fe --- /dev/null +++ b/crates/ui-egui/tests/ui_settings_info.rs @@ -0,0 +1,113 @@ +//! Settings remain a single transaction while GPU Information is open. +use std::sync::Arc; + +use effectcraft_engine::{Session, config::MemoryConfig}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable as _}; +use serde_json::json; + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + let r = Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])); + assert!(h.ctx.content_rect().contains_rect(r), "visible {id}: {r:?}"); + r +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn saved(store: &Arc) -> Session { + let mut session = Session { config: Some(store.clone()), ..Default::default() }; + session.prefs.disk.disk_cache_enabled = false; + session.load_settings(); + session +} + +fn check_info_transaction(via_escape: bool) { + let store = Arc::new(MemoryConfig::default()); + let mut session = Session { config: Some(store.clone()), ..Default::default() }; + session.prefs.disk.disk_cache_enabled = false; + session.save_prefs(); + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let original_brightness = session.prefs.appearance.brightness; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"appearance"})).unwrap(); + h.run_steps(3); + let brightness = rect(&h, "settings.appearance.brightness"); + click(&mut h, brightness.center() + vec2(brightness.width() * 0.25, 0.0)); + let edited_brightness = h.state().session.prefs.appearance.brightness; + assert!((edited_brightness - original_brightness).abs() > 0.01, "real brightness slider changed"); + assert_eq!(saved(&store).prefs.appearance.brightness, original_brightness, "unconfirmed edit is not saved"); + // Use the upstream percent-valued Scale choice; keyboard zoom is deliberately disabled. + let at = rect(&h, "settings.appearance.uiScale").center(); + click(&mut h, at); + let at = h.get_by_label("125%").rect().center(); + click(&mut h, at); + assert_eq!(h.state().session.prefs.appearance.ui_scale, 125); + assert_eq!(saved(&store).prefs.appearance.ui_scale, 100, "unconfirmed scale edit is not saved"); + // Let UI Scale settle the centered dialog before reading its next hit target. + h.run_steps(2); + let at = rect(&h, "settings.page.previews").center(); + click(&mut h, at); + let at = rect(&h, "settings.button.GPU Information").center(); + click(&mut h, at); + assert_eq!(h.state().dialog, Some(Dialog::Info), "real Settings button opened GPU Information"); + assert!(h.query_by_label("GPU Information").is_some(), "rendered GPU Information dialog"); + assert_eq!(h.state().session.prefs.appearance.ui_scale, 125); + assert_eq!(saved(&store).prefs.appearance.ui_scale, 100, "Info preserves the unconfirmed Settings transaction"); + if via_escape { + for pressed in [true, false] { + h.event(Event::Key { key: Key::Escape, physical_key: Some(Key::Escape), pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + } else { + let at = h.get_by_label_contains("OK").rect().center(); + click(&mut h, at); + } + eprintln!( + "after Info dismissal: dialog={:?}, live brightness={}, live scale={}", + h.state().dialog, + h.state().session.prefs.appearance.brightness, + h.state().session.prefs.appearance.ui_scale + ); + assert_eq!(h.state().dialog, Some(Dialog::Settings), "dismissing GPU Information returns to the pending Settings transaction"); + assert_eq!(h.state().session.prefs.appearance.brightness, edited_brightness); + if via_escape { + let at = rect(&h, "settings.ok").center(); + click(&mut h, at); + let reopened = saved(&store); + assert_eq!(reopened.prefs.appearance.brightness, edited_brightness, "Settings OK saves the earlier live edit"); + assert_eq!(reopened.prefs.appearance.ui_scale, 125); + } else { + let at = rect(&h, "settings.cancel").center(); + click(&mut h, at); + assert_eq!(h.state().session.prefs.appearance.brightness, original_brightness); + assert_eq!(h.ctx.zoom_factor(), 1.0); + assert_eq!(saved(&store).prefs.appearance.brightness, original_brightness); + assert_eq!(saved(&store).prefs.appearance.ui_scale, 100); + h.state_mut().set_pref("appearance.uiScale", json!(150)).unwrap(); + h.run_steps(3); + assert_eq!(saved(&store).prefs.appearance.ui_scale, 150, "preference edits outside Settings persist normally"); + } + assert!(h.state().dialog.is_none()); +} + +#[test] +fn gpu_information_ok_returns_to_settings_and_cancel_restores_preferences() { + check_info_transaction(false); +} + +#[test] +fn gpu_information_escape_returns_to_settings_and_ok_saves_preferences() { + check_info_transaction(true); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index cfb1db37..6b535b6c 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -368,6 +368,201 @@ fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() { assert!(!h.state().session.state.shape_tool.creates_mask); } +// ---- the shape tools' drag ghost + +/// Run frames until the viewer's background frame has landed, so the comp is painted (and not +/// still black) before the drag begins: the ghost is then the only thing that changes. +fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still +/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers +/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set. +fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage { + h.input_mut().events.push(Event::ModifiersChanged(mods)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.render().expect("the mid-drag frame") +} + +/// Release the button where the drag left it. +fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + h.run_steps(2); +} + +/// The pixel at a comp point of a rendered frame. +fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] { + let s = screen(h, p); + let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0; + [p[0], p[1], p[2]] +} + +/// How different two pixels are, summed over the channels. +fn delta(a: [u8; 3], b: [u8; 3]) -> u32 { + (0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum() +} + +/// The Tools-bar Fill as bytes, the colour a filled ghost carries. +fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { + let c = h.state().session.state.shape_tool.fill.color; + std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// The shape tools show the shape they will draw under the cursor while the drag is still going: +/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was +/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box. +#[test] +fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { + let (from, to) = ([140.0, 100.0], [400.0, 300.0]); + let centre = [270.0, 200.0]; + // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. + let corner = [146.0, 106.0]; + for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { + let mut h = harness(); + h.state_mut().ui.tool = tool; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let before = h.render().expect("the frame before the drag"); + let mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let fill = fill_rgb(&h); + + // Drawn while the button is down, and painted with the Fill rather than left to a hairline. + let was = px_at(&before, &h, centre); + let now = px_at(&mid, &h, centre); + assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})"); + assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})"); + + // The tool's own form: only the Rectangle reaches the corner of its box. + let was_c = px_at(&before, &h, corner); + let now_c = px_at(&mid, &h, corner); + assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape"); + + release(&mut h, b, egui::Modifiers::default()); + } +} + +/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a +/// lie: Shift squares the box and Ctrl/Cmd draws it from the press point (as in After Effects), both live. +#[test] +fn shape_drag_ghost_normalises_the_same_way_as_the_release() { + let from = [140.0f64, 100.0]; + let to = [400.0f64, 260.0]; + // (mods, centre, size): Shift squares the box from its top-left, Ctrl/Cmd centres it on the press. + for (mods, centre, size) in [ + (egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]), + (egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]), + (egui::Modifiers::COMMAND, [140.0, 100.0], [520.0, 320.0]), + (egui::Modifiers { shift: true, ..egui::Modifiers::COMMAND }, [140.0, 100.0], [520.0, 520.0]), + ] { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32))); + let before = h.render().expect("the frame before the drag"); + // Measured with the button down, so this is the ghost and not the committed shape. + let mid = mid_drag(&mut h, a, b, mods); + let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0]; + assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land"); + release(&mut h, b, mods); + + // And the shape it commits is that box: a new shape layer carries the position, its + // group sits at its own centre's origin. + let comp = h.state().session.active_comp().unwrap().clone(); + let layer = comp.layers.first().expect("a new shape layer").clone(); + assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer"); + let at = layer.transform().unwrap().get("position").unwrap().value.components(); + assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}"); + let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone(); + let path = group.sub("contents").unwrap().sub("ellipse").unwrap(); + let s = path.get("size").unwrap().value.components(); + assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}"); + } +} + +/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour +/// and not painted with the Fill, where a shape drawn into the selected shape layer is. +#[test] +fn shape_drag_ghost_shows_where_the_shape_will_land() { + let (from, to) = ([140.0, 100.0], [400.0, 300.0]); + let centre = [270.0, 200.0]; + + // Nothing selected: the shape becomes a new layer, painted with the Fill. + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let shape_px = px_at(&shape_mid, &h, centre); + let fill = fill_rgb(&h); + assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour"); + release(&mut h, b, egui::Modifiers::default()); + + // A solid selected: the same drag is a mask on it, in the mask colour. + let mut h = harness(); + let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let mask_px = px_at(&mask_mid, &h, centre); + assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})"); + release(&mut h, b, egui::Modifiers::default()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask"); +} + +/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces +/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting +/// for it would freeze the viewer on the last completed frame for the whole drag. +#[test] +fn a_layer_drag_moves_the_picture_while_the_button_is_down() { + let mut h = harness(); + let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap(); + settle_viewer(&mut h); + let from = screen(&h, [320.0, 180.0]); + let to = screen(&h, [520.0, 180.0]); + 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(); + // Where the layer is not (yet): the plate behind it. + let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]); + + // Drag in small steps, still holding the button, and give the renders a chance to land. + let mut moved = false; + for i in 1..=40 { + let p = from + (to - from) * (i as f32 / 40.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + // 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor + // icon and where it started. + let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]); + if i >= 30 && delta(at, plate) > 30 { + moved = true; + break; + } + } + assert!(moved, "the layer's pixels did not follow the pointer while the button was down"); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + /// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the /// next shape drawn (#227). #[test] diff --git a/docs/agents.md b/docs/agents.md index f3618f60..a0f4e7d0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -43,6 +43,23 @@ Desktop, Cursor and the like) take the same `command` and `args`. - **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you see every change live. Bridge mode adds `screenshot` and the `ui_*` tools. +### From an installed release + +The release packages ship `effectcraft-cli` alongside the desktop app, so no build is needed: + +| Install | CLI | +|---|---| +| Windows (MSI) | `C:\Program Files\EffectCraft\effectcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Linux (deb, rpm) | `/usr/bin/effectcraft-cli` | +| macOS | the separate `effectcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | + +```sh +# Windows, default install folder +claude mcp add effectcraft -- "C:\Program Files\EffectCraft\effectcraft-cli.exe" mcp +# Linux, or macOS with the CLI unzipped onto PATH +claude mcp add effectcraft -- effectcraft-cli mcp +``` + The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MCP protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 (`initialize`, `ping`, `tools/list`, `tools/call`). @@ -86,9 +103,9 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. | | `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | | `get_layer {layer, comp?, time?, depth?, flat?}` | A layer's property tree (`depth` limits how many group levels expand). Every node has a `path`. | -| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | -| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | -| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?, comp?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | +| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | +| `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | +| `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | | `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | | `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | @@ -111,6 +128,28 @@ example `transform/position`, `transform/opacity`, `effects/#1/blurriness` or `@ are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or stretched. +Use MCP `execute_command` for engine commands `prop.set`, `prop.addKey`, `keys.set` and +`keys.select` with `timeBase: "layer" | "comp"`; omitted means `layer`. The convenience tools +`set_property` and `add_keyframe` take the same `timeBase` (layer time when omitted). The input base applies to +both `keys.set.time` and `newTime`, and to every `keys.select.keys[].time`. +Omitted `prop.set` or `prop.addKey` times use the target comp's CTI. Supplied write times snap +to its nearest comp frame before conversion to stored layer time, including stretched or +reversed layers; existing `keys.set` neighbor constraints and roving adjustment still apply. + +Explicit bases add timing metadata to mutation replies; `keys.select` reports timing in +`keys[]` for the resulting selection. Returned key `time` and `layerTime` are layer seconds; +`compTime` is comp seconds. Omitted-base `prop.set`, `prop.addKey` and `keys.select` retain +their existing JSON value/count replies even outside the active range. Timing metadata +carries outside-range warnings; the existing objects from `keys.set` and `prop.get.keys[]` +also report both bases and warnings. `prop.get.time` is comp seconds. Separated Position +`prop.set` retains per-axis replies; for these commands, `path` or `prop` identifies a property, not a group. + +Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds. +For an unstretched layer starting at 10.5 s in a 30 fps comp, +`prop.addKey {layer: "A", path: "transform/opacity", time: 15.3, +timeBase: "comp", value: 0}` writes a key at comp 15.3 s (layer 4.8 s), matching +`prop.get {layer: "A", path: "transform/opacity", time: 15.3}`. + Command params are checked against each command's `params` doc. An unknown key, such as `layer.select {"index": 2}`, returns an error that lists the accepted keys (here `layers, add, toggle`) instead of being ignored. @@ -149,11 +188,11 @@ the Progress panel, `jobs.list`); headless sessions run `footage.check` when the Things that tripped up a long agent-driven session (a multi-scene 3D piece built entirely over MCP). -- **Layer time vs comp time.** `add_keyframe`, `set_property` with `time`, `prop.addKey`, - `keys.select` and `keys.set` take layer time; `get_property` and `render_frame` take comp time; - `run_script`'s `setValueAtTime` and `keyTime` use comp time, as in After Effects. On a layer that - doesn't start at 0, such as a nested comp placed later, subtract the layer's start time from the - comp time before keying (unstretched layers), or set the keys from `run_script` +- **Layer time vs comp time.** `add_keyframe` and `set_property` with `time` take layer time; + `get_property` and `render_frame` take comp time. Those tools and the engine keyframe commands + default to layer time; pass `timeBase: "comp"` to supply comp seconds on offset or + stretched layers. `run_script`'s `setValueAtTime` and `keyTime` also use comp time. Without an + explicit engine time base, subtract the layer's start time before keying an unstretched layer ([#257](https://github.com/storytold/effectcraft/issues/257)). - **Moving a layer in time.** `execute_command layer.timing {"layers":["Scene 2"],"start":5.1}` sets the start time in comp seconds and moves the in and out points with it. @@ -240,6 +279,14 @@ build real projects through these interfaces; they are worked examples of everyt * **Essential Graphics**: controls can be addressed by name (`essential.set {"layer":"#1", "control":"Title","value":"John Smith"}`); a command that targets an explicit `comp` runs even when the active comp would disable it (`essential.exportTemplate {"comp":"Lower Third", …}`). + The CLI also renders an exported template directly: + `effectcraft-cli render --template lt.ectemplate --values values.json --out 'title_[#####].png'`. + `values.json` is an object such as `{"Title":"John Smith","Bar Color":"#00c080","Bar Opacity":50}`; + names follow `essential.set`, including its case-insensitive matching. Values override one + imported instance; the template file and its source controls keep their defaults. Omit + `--values` to render those defaults, or use the same options with `render-frame` for a PNG. + Values must fit in 1 MiB. Invalid JSON, unknown controls and invalid values fail before rendering. + Template mode cannot be combined with `--project`, `--demo`, `--bridge`, `--queue` or `--comp`. * **History**: Levels of Undo defaults to 32 (`prefs.set {"key":"general.undoLevels","value":99}`); `history` lists branches and `history {"goto": id}` jumps between them. A command that changes nothing (a value set to what it already is) records no undo step. `get_state` reports `dirty` diff --git a/docs/architecture.md b/docs/architecture.md index 33e38215..4fe5d2cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,12 +165,16 @@ editing one layer re-renders only that layer. Each Render Queue job keeps its ow for its frames. Effects that read the clock directly are declared in `effects::TIME_DEPENDENT`, and a test checks every registered effect against that list. -The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by project -revision, comp, frame, scale, 3D view / region of interest and a hash of the other render -options (Draft / Fast Previews, shadows, nested switches), so frames rendered under other -options are never shown. When the budget (Settings ▸ Memory & CPU) is full the least recently -shown frames go first, and frames of an older revision are dropped as soon as the project -changes. The timeline's green bar counts the frames of exactly what the viewer shows. +The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by comp +content, frame, scale, 3D view / region of interest and a hash of the other render options +(Draft / Fast Previews, shadows, nested switches). Edits preserve unaffected comps' frames, +and undo can reuse earlier content. During rapid edits at the same time and view, the viewer +shows the newest completed revision while the exact requested revision renders; out-of-order +completions cannot move it backwards. This avoids freezing until a slider or layer drag stops. +Frames at other times or with other render options are never substituted. Obsolete queued jobs +are dropped; running jobs finish into the cache. When the budget (Settings ▸ Memory & CPU) is +full the least recently shown frames go first. The timeline's green bar counts cached frames +of the current content and viewer settings. Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above 50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain @@ -230,7 +234,11 @@ Nulls / Nulls Follow Points take pins as well as paths, and `puppet.follow` give Position expression that trails the leader by a delay. The renderer flattens nested effect groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `puppet.*`. -Half, Third and Quarter resolution render proportionally fewer pixels end to end. +Half, Third and Quarter resolution render proportionally fewer pixels end to end. Footage +starts at the render's size where the source can make it more cheaply than the full frame +(`FootageSource::frame_at_size`): movies in opaque 4:2:0 Y'CbCr are box-filtered to half size +before conversion (a quarter of the conversions; the result differs from converting first only +in blocks where colours clip) and resampled from there, as other frames are from full size. **GPU compositor** (`crates/gpu`; Project Settings ▸ Video Rendering and Effects ▸ Mercury GPU Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9d785bd1..0a3f6eda 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -124,3 +124,20 @@ Editor transform box, timeline Alt-drag scaling). The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol; see [agents.md](agents.md). + +Keyframe command times (`prop.set`, `prop.addKey`, `keys.set`, `keys.select`) default to layer +seconds. Set `timeBase: "comp"` for comp seconds; `keys.set.newTime` and every +`keys.select.keys[].time` use the same input base. Omitted `prop.set` or `prop.addKey` times use +the target comp's CTI. Supplied write times snap to its nearest comp frame before conversion +to stored layer time, including on stretched or reversed layers. Existing `keys.set` neighbor +constraints and roving adjustment can change the final stored time. + +Explicit bases add `layerTime` and `compTime` to unsplit `prop.set` and `prop.addKey` replies, +alongside `value` or `count`. Separated Position `prop.set` keeps an array of per-axis replies. +Explicit `keys.select` replies contain `count` and `keys[]`, with timing on each key in the +resulting selection. Key metadata's `time` remains layer seconds regardless of the input base. +Without `timeBase`, these three commands retain their existing JSON value/count replies, +including outside the layer's `[in, out)` range. Timing metadata carries outside-range warnings; +`keys.set` and `prop.get.keys[]` report both bases and warnings without an explicit base too. +`prop.get.time` is comp seconds. For these commands, `path` or `prop` identifies a property, not a group. +Set a layer's offset with `layer.timing {layer, start}` (comp seconds). diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 682ea24e..ae88186b 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -52,6 +52,10 @@ the 168 live AE samples. ## Still unmeasured +Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` +helper. Corpus runners can use it with locally generated host/reference frames and fail on either a +mean or maximum channel error without committing proprietary renders. + Mixed interpolation sides, non-unit durations, automatic/continuous Bezier, spatial speeds and paths, time remapping, expressions, actual rendered motion and other effects need separate corpus cases. The initial probe makes no claim about those features. diff --git a/docs/footage.md b/docs/footage.md index 9a933f03..784a75ad 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -93,3 +93,22 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la "sequence": false}` for one frame). - **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder (a render still in progress) and keeps its interpretation. + +**File ▸ Dependencies ▸ Relink Missing Footage…** searches a chosen folder and its subfolders +for missing source files on the desktop. It relinks unique exact filenames whose decoded type, +dimensions, codec and audio/video flags agree with the saved footage (movies and audio also +require matching native frame rate and frame count). The Progress panel reports completion; +the result includes each candidate's high or low confidence and reason. Confidence is a metadata +heuristic, not proof of identical contents. Duplicate filenames remain unresolved even if only +one decodes successfully; use Replace Footage to choose them manually. + +Relinking preserves item names, interpretation, layer timing and the saved sequence range, and +the entire operation is one undo step. Sequences require every saved frame in the new folder; +extra frames do not extend the range. Existing files are untouched. Layer-specific/page-specific +footage, models and embedded data are left unresolved. Symlinks are skipped, unreadable folders +fail the search without edits, and searches are limited to 100,000 entries. Browser folder +relinking is unavailable. + +Agents: `file.relinkFootage {"folder": "/moved/shots", "dryRun": true, "wait": true}` returns +a report without edits. Omit `dryRun` to apply unique high-confidence matches. Without `wait`, +the command returns a cancellable job; `jobs.wait` / `jobs.list` expose its report. diff --git a/docs/gaps.md b/docs/gaps.md index 18870661..e089534e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,7 +34,7 @@ of them have been reconciled. | Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) | | Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | | Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | -| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and since 9 October the panels, dialogs and tooltips too (Simplified and Traditional Chinese; the Japanese rows of the panel catalog are still open); no accessibility work | +| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work | | Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested | | Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | | AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height | @@ -80,6 +80,12 @@ The most important missing piece: it turns every other estimate here into a meas Project items, multiple selection and undo. They check that an unrelated Timeline layer stays, an empty Project selection does nothing, viewer focus still deletes layers, and typing or dialogs do not delete items. +- Regression evidence for [#484](https://github.com/storytold/effectcraft/issues/484): the + `shift_delete_*` input tests in `ui_project_delete`, checked headlessly on Windows, press + Shift+Delete both as a key and as the Cut event Windows sends for it. Items in use go without + 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 the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template diff --git a/docs/mcp.md b/docs/mcp.md index 9f10f403..0d7f83e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -22,7 +22,10 @@ open-world hints. Tools with an optional output path are conservatively annotate Unknown top-level tool argument keys return JSON-RPC `-32602` naming the key and accepted arguments. EffectCraft already rejects unknown engine command parameters; that behaviour is -preserved. Tool failures (including escaped panics) return `isError: true`. Malformed JSON +preserved. Tool failures (including escaped panics) return `isError: true`, and so does any JSON +result with a `failed` count above zero (a `command_batch` / `batch` with a failed step, a +render whose queue items did not all finish), even though the other steps or items succeeded: +read `results` / `items` for the details. Malformed JSON returns `-32700` with a null id and the session keeps serving. The MCP backend owns its session without a mutex; the engine's render-job mutexes already recover poisoned locks. @@ -41,8 +44,19 @@ Headless `command_run` / `execute_command` calls to `renderQueue.render` (unless run the existing background job while the MCP server keeps reading stdin. A `params._meta.progressToken` string or number opts into `notifications/progress`, at most ten per second, strictly increasing with a queue-item total. No token means no notifications. -`ping`, inspection and other requests are served while rendering. The existing engine refuses -a second render while one is active. Closing stdin lets the pending render finish and reply. +`ping`, inspection and other requests are served while rendering. Closing stdin lets the +pending render finish and reply. + +### Requests during a render + +A render job owns a snapshot of the project taken when it started. While it runs: + +- Every way of starting a second render is refused with `isError: true`: a blocking + `renderQueue.render`, one with `wait: false`, or one inside a batch. +- Other commands, including edits, run normally and change the project. They do not affect the + running job, which keeps rendering its snapshot; to render an edit, render again after the + job ends. Queue items are matched by id when the job reports their status, so an item removed + mid-render is left out of the reply and counted in `failed`. Send `notifications/cancelled` with `params.requestId` to stop the matching request at the next frame batch. No response is sent for that cancelled request. Unknown and completed ids are @@ -52,5 +66,7 @@ queue items and skipped existing frames remain. Custom exporters implement the s contract. A failed queue item gives `isError: true`. Bridge calls, `wait: false`, batch tools and single-frame tools retain synchronous/polling -behaviour and do not report MCP progress or cancellation. Use a direct blocking command call +behaviour and do not report MCP progress or cancellation. `renderQueue.render {"wait": false}` +returns the ids being rendered at once; poll `renderQueue.list` for status and stop it with +`renderQueue.stop`. Use a direct blocking command call for an MCP-cancellable render. diff --git a/docs/preferences.md b/docs/preferences.md index 9e3d4147..3e3fa878 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -29,7 +29,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`; the browser build stays in English with it. Choose **Українська** for Ukrainian menus; Ukrainian dialog and panel contents stay in English for now, and Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown @@ -54,7 +54,10 @@ Commands (CLI, MCP, control channel): without input or edits, the viewer renders the work area into the RAM preview in the background, from the current time on, until it is cached or the budget is full - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) -- `appearance.theme`: Theme +- `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). +- `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance +- `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the display's own scaling), applied as soon as it is chosen @@ -71,8 +74,8 @@ Commands (CLI, MCP, control channel): - `audio.outputDevice`: Default Output (device list from the desktop host) - `audio.outputLeft`: Left (Audio Output Mapping) - `audio.outputRight`: Right (Audio Output Mapping) -- `memory.layerCacheMb`: Layer Cache -- `memory.mediaCacheMb`: Footage Frame Cache +- `memory.layerCacheMb`: Layer Cache (in the browser at most 512 MB, see [web.md](web.md)) +- `memory.mediaCacheMb`: Footage Frame Cache (in the browser at most 256 MB) - `memory.previewCacheMb`: Preview (RAM) Cache - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, @@ -109,6 +112,14 @@ Commands (CLI, MCP, control channel): - `type.fontNamesInEnglish`: off, fonts with a native-language family name show it - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) +- `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as + in After Effects) or `all` (image sequences too). Every two seconds, + and as soon as the window comes back to the front, the desktop app compares each footage file's + size and modification time with what they were, and reloads what another app saved: a + Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload + Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer + (found again by name), and a file still being written is tried again on the next check. + `footage.reloadChanged` runs the same check once (CLI, agents) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) - `import.dragImportAs`: layered files dropped on the window import as footage or a comp diff --git a/docs/releasing.md b/docs/releasing.md index 746bc2ec..dfba8926 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -66,6 +66,13 @@ jobs are refused by the environment's branch rule, and no draft release is made. The ARM64 MSI is installed and run on ARM64 hardware by [`windows-arm64.yml`](../.github/workflows/windows-arm64.yml). +The MSI's setup wizard ([`installer-ui.wxs`](../packaging/windows/installer-ui.wxs)) lets people +choose the install folder; upgrades and repairs reuse it, and `INSTALLFOLDER="..."` picks it for a +silent (`/qn`) install. [`packaging-lint.yml`](../.github/workflows/packaging-lint.yml) install-tests +that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a custom folder, +an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages +themselves are checked by hand. + Every binary reports its version: `effectcraft --version`, `effectcraft-cli --version` and *Help › About EffectCraft*. diff --git a/docs/web.md b/docs/web.md index b03086eb..e73f7a89 100644 --- a/docs/web.md +++ b/docs/web.md @@ -61,6 +61,7 @@ device of their own), `?nosw` (don't register the service worker). | config directory: settings, shortcut presets, recent projects, the recovery sentinel | `store::WebConfig` in browser storage | | auto-save folder | `/EffectCraft Auto-Save/` in browser storage (`ConfigStore::files`) | | media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` | +| Memory & CPU cache budgets, scaled to the system's RAM | the page's memory, at most 4 GiB (wasm32), which the caches share with the decoders' own decoded frames (FilmCraft keeps up to 384 MB per open movie): the Layer Cache stays within 512 MB and the Footage Frame Cache within 256 MB (`Prefs::cache_budgets`) | | export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` | | rfd file dialogs | `` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name | | Media Browser on the file system | browser storage and folders opened with the File System Access API (below) | diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 606da5e9..3e3b8894 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,25 +45,44 @@ NSHumanReadableCopyright Copyright © the EffectCraft authors. MIT OR Apache-2.0. - + + CFBundleDocumentTypes + + + CFBundleTypeName + EffectCraft Project + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + EffectCraft + LSItemContentTypes + ai.storyteller.effectcraft.document + + + UTExportedTypeDeclarations UTTypeIdentifier ai.storyteller.effectcraft.document UTTypeDescription - EffectCraft Document + EffectCraft Project UTTypeConformsTo public.data - public.image + public.content UTTypeIconFile EffectCraft UTTypeTagSpecification public.filename-extension - pcraft + + ecproj + ecprojx + public.mime-type application/x-effectcraft diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index 5a70002b..f4710add 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -2,11 +2,9 @@ + + @@ -30,6 +30,25 @@ + + + + + + + + + + + @@ -50,6 +69,7 @@ + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs new file mode 100644 index 00000000..14989932 --- /dev/null +++ b/packaging/windows/installer-ui.wxs @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 18551225..7f1320c8 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,7 +93,7 @@ Copy-Item (Join-Path $Bin 'effectcraft.exe'), (Join-Path $Bin 'effectcraft-cli.e # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "effectcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') -arch $Arch ` + wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" ` -o $Msi } diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 new file mode 100644 index 00000000..0bed8c43 --- /dev/null +++ b/packaging/windows/test-msi.ps1 @@ -0,0 +1,124 @@ +<# +.SYNOPSIS + Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). + +.DESCRIPTION + Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App + Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: + 1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...), + 2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the + installed effectcraft.exe, as there is no Path value yet), + 3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote), + 4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode, + 5. uninstalls: files and the App Paths registration go, + 6. installs 0.0.3 with no folder: it must land in Program Files, then uninstalls. + The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn + and upgrades do with the folder. Needs an elevated shell and WiX v5 (`wix`) on PATH. + Run by .github/workflows/packaging-lint.yml. + +.EXAMPLE + pwsh packaging/windows/test-msi.ps1 +#> +param([string] $Work = (Join-Path ([IO.Path]::GetTempPath()) 'effectcraft-msi-test')) +$ErrorActionPreference = 'Stop' +trap { Write-Output "FAILED: $_"; exit 1 } +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' + +function Invoke-Native([string] $What, [scriptblock] $Block) { + Write-Output "==> $What" + & $Block + if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" } +} +function Invoke-Msiexec([string] $What, [string[]] $Arguments) { + $log = Join-Path $Work "$($What -replace '\W', '-').log" + $p = Start-Process msiexec.exe -ArgumentList ($Arguments + '/qn', '/l*v', "`"$log`"") -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content $log -Tail 60; throw "msiexec ($What) exited $($p.ExitCode)" } + Write-Output "ok $What" +} +function Assert-InstalledIn([string] $Dir, [switch] $NoPathValue) { + foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') { + if (-not (Test-Path -LiteralPath (Join-Path $Dir $exe))) { throw "$exe is not in $Dir" } + } + $key = Get-ItemProperty -LiteralPath $AppPaths + $want = Join-Path $Dir 'effectcraft.exe' + if ($key.'(default)' -ne $want) { throw "App Paths points at '$($key.'(default)')', expected '$want'" } + if ($NoPathValue) { + if ($null -ne $key.Path) { throw "the legacy package wrote an App Paths Path value" } + } elseif ($key.Path.TrimEnd('\') -ne $Dir.TrimEnd('\')) { + throw "App Paths Path is '$($key.Path)', expected '$Dir'" + } + Write-Output "ok installed in $Dir" +} + +Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue +$Bin = Join-Path $Work 'bin' +New-Item -ItemType Directory -Force -Path $Bin | Out-Null +# The MSI only copies these. The app is versioned (it embeds VERSIONINFO); the real CLI has no +# version resource, so its stand-in is an unversioned file too: Windows Installer keeps a changed +# unversioned file on repair unless the repair mode forces it. +Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') +[IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') + +# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value. +$Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' +$LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' +$pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' +$text = Get-Content -Raw $Wxs +if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" } +[IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, '')) + +$Msi = @{} +foreach ($v in '0.0.1', '0.0.2', '0.0.3') { + $Msi[$v] = Join-Path $Work "effectcraft-$v.msi" + $src = if ($v -eq '0.0.1') { $LegacyWxs } else { $Wxs } + Invoke-Native "wix build $v" { + wix build $src (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch x64 ` + -d "Version=$v" -d "BinDir=$Bin" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" -o $Msi[$v] + } + Invoke-Native "wix msi validate $v" { wix msi validate $Msi[$v] } +} + +# Spaces and a non-ASCII letter (written as a char so the script's own encoding doesn't matter). +$Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My Apps" +$Default = Join-Path $env:ProgramFiles 'EffectCraft' +if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed here; run this on a clean machine' } + +Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"") +Assert-InstalledIn $Custom -NoPathValue +if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" } + +Invoke-Msiexec 'upgrade the legacy install without a folder' @('/i', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Custom +if (Test-Path -LiteralPath $Default) { throw "the upgrade moved the install to $Default" } + +Invoke-Msiexec 'upgrade again without a folder' @('/i', "`"$($Msi['0.0.3'])`"") +Assert-InstalledIn $Custom +# Read single values: Get-ItemProperty throws on some machines' malformed Uninstall entries. +$arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | + ForEach-Object { if ($_.GetValue('DisplayName') -eq 'EffectCraft') { $_.GetValue('DisplayVersion') } }) +if ($arp.Count -ne 1 -or $arp[0] -ne '0.0.3') { throw "expected one EffectCraft 0.0.3 in Apps, found: $($arp -join ', ')" } + +# Repair the way the wizard's Repair button does: damage the CLI in place (a later write time, as a +# user edit or corruption would have), repair with the wizard's REINSTALLMODE, compare contents. +$mode = [regex]::Match((Get-Content -Raw (Join-Path $PSScriptRoot 'installer-ui.wxs')), 'Event="ReinstallMode" Value="([^"]+)"').Groups[1].Value +if (-not $mode) { throw 'no ReinstallMode in installer-ui.wxs' } +$cli = Join-Path $Custom 'effectcraft-cli.exe' +Start-Sleep -Seconds 2 +[IO.File]::WriteAllText($cli, 'damaged') +Invoke-Msiexec "repair ($mode)" @('/i', "`"$($Msi['0.0.3'])`"", 'REINSTALL=ALL', "REINSTALLMODE=$mode") +Assert-InstalledIn $Custom +if ((Get-FileHash -LiteralPath $cli).Hash -ne (Get-FileHash -LiteralPath (Join-Path $Bin 'effectcraft-cli.exe')).Hash) { + throw "repair ($mode) left the damaged effectcraft-cli.exe in place" +} +Write-Output "ok repair ($mode) restored effectcraft-cli.exe" + +Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.3'])`"") +if (Test-Path -LiteralPath (Join-Path $Custom 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' } + +Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.3'])`"") +Assert-InstalledIn $Default +Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.3'])`"") +if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +Write-Output 'MSI folder handling ok'