diff --git a/Cargo.lock b/Cargo.lock index 4a4043140..52812471c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1086,6 +1086,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1329,7 +1340,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "cpal", "craft-update", @@ -1355,7 +1366,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1367,14 +1378,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1390,7 +1401,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1398,7 +1409,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "effectcraft-color", @@ -1416,7 +1427,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "craft-docpath", @@ -1451,7 +1462,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1481,7 +1492,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.17.0" +version = "0.18.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1498,7 +1509,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1506,7 +1517,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1528,14 +1539,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1560,7 +1571,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1579,7 +1590,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1591,7 +1602,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1609,7 +1620,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1638,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "image", @@ -1650,14 +1661,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1669,7 +1680,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1685,7 +1696,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1697,7 +1708,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1710,14 +1721,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.17.0" +version = "0.18.0" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1726,7 +1737,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1748,7 +1759,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.17.0" +version = "0.18.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1766,7 +1777,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.17.0" +version = "0.18.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1775,7 +1786,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1787,7 +1798,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1799,7 +1810,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1807,7 +1818,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1819,9 +1830,10 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", + "craft-quit", "craft-ui", "craft-update", "effectcraft-color", @@ -1853,11 +1865,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.17.0" +version = "0.18.0" [[package]] name = "effectcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7183,7 +7195,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8c82c7281..aa917fc63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.17.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,6 +13,7 @@ repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6e5b8d6cc..fb00fc90d 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -279,6 +279,9 @@ fn utf8_args(raw: impl IntoIterator) -> Result raw, Err(e) => fail_usage(&e), diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 9346acc29..c8e91a260 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -7,6 +7,23 @@ // // Plain functions only: the Rust side owns the logic. +// ------------------------------------------------------------------ shortcuts + +/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks. +/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because +/// eframe falls back to the key code there; it does not on Dvorak, where it types E. +/// eframe still receives the key; other chords and HTML inputs keep their browser behavior. +export function installCanvasShortcuts(canvas) { + canvas.addEventListener("keydown", (event) => { + if (event.target === canvas && (event.ctrlKey || event.metaKey) + && !event.shiftKey && !event.altKey && !event.isComposing + && (event.key.toLowerCase() === "d" + || (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) { + event.preventDefault(); + } + }, { capture: true }); +} + // ------------------------------------------------------------------ storage const DIR = "effectcraft"; diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 169529e5d..1dcaf1334 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -15,6 +15,8 @@ use wasm_bindgen::prelude::*; extern "C" { #[wasm_bindgen(js_name = showDeviceLost)] fn show_device_lost(message: &str, kept: bool); + #[wasm_bindgen(js_name = installCanvasShortcuts)] + fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement); } /// Work for the UI thread with access to the app (posted by async tasks: picked files…). @@ -204,6 +206,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; + install_canvas_shortcuts(&canvas); let empty = query_flag("empty"); let home = query_flag("home"); // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 206314e4b..f6aac5aa4 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -151,6 +151,10 @@ impl Services for WebServices { fn is_dir(&self, _path: &str) -> bool { false } + /// The table's keys are the paths themselves. + fn full_path(&self, path: &str) -> Option { + Some(path.to_string()) + } } /// Media import from the table (the bytes are also handed to the media pool, which decodes them). diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 063015236..3728e59e2 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -8,6 +8,8 @@ // - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; // the reload writes pending changes to browser storage first, and a failed write is reported // instead of reloading (#225). +// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering +// the key to the app; other chords and HTML inputs keep their browser behavior (#2495). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; @@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); +// #2495: prevent the browser default without swallowing the app's Duplicate key. +const canvas = new EventTarget(); +const input = new EventTarget(); +host.installCanvasShortcuts(canvas); +let delivered = 0; +canvas.addEventListener("keydown", () => delivered++); +const key = (target, properties) => { + const event = new Event("keydown", { cancelable: true }); + Object.assign(event, { key: "d", ...properties }); + target.dispatchEvent(event); + return event.defaultPrevented; +}; +assert.equal(key(canvas, { ctrlKey: true }), true); +assert.equal(key(canvas, { metaKey: true, key: "D" }), true); +for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) { + assert.equal(key(canvas, properties), false); +} +// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D. +assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true); +// Dvorak: the physical D key types "e", which is not Duplicate. +assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false); +assert.equal(delivered, 9, "all keys still reach the app"); +assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut"); + // #226: one readable and one unreadable file. const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); const picked = host.browsePickFiles(false); diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 17bbb7e1b..452196224 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -365,14 +365,11 @@ fn main() -> eframe::Result { })) }), ); - match &result { - Ok(()) => log::info!("the window closed"), - Err(e) => { - if on_gl { - launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref()); - } - startup_failed(e, !on_gl && cfg!(not(target_os = "macos"))); + if let Err(e) = &result { + if on_gl { + launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref()); } + startup_failed(e, !on_gl && cfg!(not(target_os = "macos"))); } result } @@ -381,9 +378,15 @@ fn main() -> eframe::Result { /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to /// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +/// +/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one +/// frame in flight, so a drag or a scrub shows one frame sooner. The bare +/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three +/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms +/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window. fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; - let mut config = eframe::WgpuConfiguration::default(); + let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { if let Some(b) = backends { setup.instance_descriptor.backends = b; @@ -455,7 +458,8 @@ struct Desktop { } impl eframe::App for Desktop { - /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass. + /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the + /// file dialogs asked for and, last, hand a close to the platform quit. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] self.menu.update(&mut self.app, ctx); @@ -464,6 +468,7 @@ impl eframe::App for Desktop { if let Some(asked) = &self.file_dialogs { sheet_dialogs::show_asked(asked, frame, ctx); } + effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -484,6 +489,9 @@ impl eframe::App for Desktop { } fn on_exit(&mut self) { + // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends + // before `eframe::run_native` returns (#341). + log::info!("the window is closing"); self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); @@ -565,6 +573,19 @@ mod tests { assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } + /// The window's surface keeps one frame in flight, like eframe's own default, not the two + /// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag). + #[test] + fn the_window_surface_is_low_latency() { + for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] { + let surface = super::wgpu_options(backends).surface; + assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}"); + assert_eq!(surface.desired_maximum_frame_latency, Some(1)); + assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync); + } + assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface"); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { diff --git a/contributors/people.toml b/contributors/people.toml index d4ce5ca67..8654c7a10 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -99,5 +99,10 @@ sync_github_name = false [people.ki2pixel] display_name = "ki2pixel" +[people.becauseno7] +real_name = "Shuvra Basak" +display_name = "Shuvra Basak" +sync_github_name = false + [people.ashokram-si] real_name = "Ashok Ram" diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..5080058fb 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() { call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}})); assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B"); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); let (c, err) = call(&mut s, "render_frame", json!({})); assert!(!err, "{c:?}"); @@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() { } } +/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with +/// only `id` and `label`, effects with only `id`, `name` and `category`. +#[test] +fn compact_listings_keep_only_ids_and_names() { + let mut s = server(); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); + let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true})); + assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}"); + // Each compact listing is the full one with the same arguments, entry for entry; `compact` + // wins over `schemas`. + let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| { + let full = call_json(s, tool, args.clone()); + let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect(); + let mut compact = args.clone(); + compact["compact"] = json!(true); + assert_eq!(call_json(s, tool, compact), want, "{tool} {args}"); + }; + for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] { + check(&mut s, "list_commands", args.clone(), &["id", "label"]); + check(&mut s, "command_list", args, &["id", "label"]); + } + check(&mut s, "list_effects", json!({}), &["id", "name", "category"]); + check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]); + // `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it. + let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string(); + for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] { + let full = text(&mut s, tool, json!({})); + assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}"); + let compact = text(&mut s, tool, json!({"compact": true})).len(); + assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len()); + } +} + #[test] fn high_level_tools_add_effect_and_report_state() { let mut s = server(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a83e0d1ec..1d6074247 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools +/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`. fn list_commands(b: &mut Backend, a: &Value) -> Result { - let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false); + let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true)); let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; - // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut c| { + if compact { + return json!({"id": c["id"], "label": c["label"]}); + } + // Drop empty fields. if let Some(o) = c.as_object_mut() { o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); } @@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("command.describe", json!({"command": id}))?) } +/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`. fn list_effects(b: &mut Backend, a: &Value) -> Result { - json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) + let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?; + if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) { + r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect(); + } + json_reply(r) } fn list_fonts(b: &mut Backend, a: &Value) -> Result { @@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result { static TOOLS: &[ToolDef] = &[ ToolDef { name: "list_commands", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) @@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "list_effects", - description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.", + description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.", bridge_only: false, - schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]), + schema: || { + schema( + json!({ + "filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}, + "compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."} + }), + &[], + ) + }, run: list_effects, }, ToolDef { @@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "command_list", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index db18c61c3..eb108a204 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,7 +3,7 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane}; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra}; use crate::tables::{ AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, @@ -122,6 +122,38 @@ struct TxResult { bits: f64, } +/// The output and scratch buffers of one reconstruction, each `n * n` entries long. +struct ReconBuffers<'a> { + deq: &'a mut [i32], + resid: &'a mut [i32], + recon: &'a mut [u16], +} + +/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`. +#[derive(Clone, Copy)] +struct TxEval { + nonzero: bool, + dist: f64, + bits: f64, +} + +/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the +/// first `n * n` entries are used; they are reused across candidates instead of being allocated. +struct TxScratch { + res: [i32; 1024], + coef: [f32; 1024], + levels: [i32; 1024], + deq: [i32; 1024], + resid: [i32; 1024], + recon: [u16; 1024], +} + +impl Default for TxScratch { + fn default() -> Self { + TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] } + } +} + /// Saved encoder state of a block area (reconstruction, BlockDecoded flags). struct Snapshot { planes: [Vec; 3], @@ -146,6 +178,8 @@ pub(crate) struct Decider<'a> { lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). half: [Vec; 3], + /// Transform buffers, taken out while a block is being tried (see `code_tx`). + scratch: Option>, } const MODE_BITS_INTRA: f64 = 3.0; @@ -178,9 +212,10 @@ impl<'a> Decider<'a> { lambda, lambda_sad: lambda.sqrt(), half: match refr { - Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)), + Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd), None => Default::default(), }, + scratch: None, } } @@ -339,8 +374,10 @@ impl<'a> Decider<'a> { let log2 = (n4 * 4).trailing_zeros(); let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize); let bd = self.bd; - let mut pred = vec![0u16; 32 * 32]; - let mut best_pred = vec![0u16; 32 * 32]; + let mut pred = [0u16; 32 * 32]; + let mut best_pred = [0u16; 32 * 32]; + let mut best_levels = [0i32; 32 * 32]; + let mut best_recon = [0u16; 32 * 32]; let n = 1usize << log2; // luma let e = IntraEdge { @@ -358,26 +395,26 @@ impl<'a> Decider<'a> { let ed = edges(&self.rec[0], &e); // modes ranked by SATD, then a rate-distortion choice of mode and transform type among // the best three - let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED - .iter() - .map(|&mode| { - predict_intra(&ed, &e, mode, &mut pred); - let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; - (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) - }) - .collect(); + let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| { + predict_intra(&ed, &e, mode, &mut pred); + let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; + (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) + }); ranked.sort_by(|a, b| a.0.total_cmp(&b.0)); let tx_y = tx_size_for(log2); - let mut best: Option<(f64, usize, usize, TxResult, Option>)> = None; + let mut best: Option<(f64, usize, usize, TxEval, bool)> = None; + let mut sc = self.scratch.take().unwrap_or_default(); for &(_, mode) in ranked.iter().take(3) { predict_intra(&ed, &e, mode, &mut pred); - let mut types = vec![DCT_DCT]; + let mut types = [DCT_DCT; 2]; + let mut n_types = 1; let alt = MODE_TO_TXFM[mode] as usize; if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) { - types.push(alt); + types[1] = alt; + n_types = 2; } - for &t in &types { - let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); + for &t in &types[..n_types] { + let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { 0.0 } else if t == DCT_DCT { @@ -389,18 +426,24 @@ impl<'a> Decider<'a> { let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits); if best.as_ref().is_none_or(|b| cost < b.0) { best_pred[..n * n].copy_from_slice(&pred[..n * n]); - best = Some((cost, mode, t, res, recon)); + best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]); + if has_recon { + best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]); + } + best = Some((cost, mode, t, res, has_recon)); } } } + self.scratch = Some(sc); // Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block // can't be coded intra (an infinite cost keeps it from being chosen). - let Some((_, y_mode, tx_type_y, ty, recon)) = best else { + let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else { let leaf = Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() }; return (leaf, f64::INFINITY); }; - self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n])); + self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] }); + let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits }; let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT }; self.mark_decoded(0, r, c, n4); // chroma (one mode for both planes) @@ -792,32 +835,36 @@ impl<'a> Decider<'a> { /// over the prediction `pred`; the reconstruction is written to `rec`. fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { let TransformBlock { plane: p, x, y, log2, .. } = block; - let (res, recon) = self.try_tx(block, pred); + let mut sc = self.scratch.take().unwrap_or_default(); + let (res, has_recon) = self.try_tx(&mut sc, block, pred); let n = 1usize << log2; - self.put(p, x, y, n, recon.as_deref().unwrap_or(pred)); - res + self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred }); + let levels = sc.levels[..n * n].to_vec(); + self.scratch = Some(sc); + TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits } } - /// Transform and quantise one block; returns the result and its reconstruction (None: the - /// prediction) without writing it. - fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option>) { + /// Transform and quantise one block without writing it. The levels are left in `sc.levels`; + /// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction). + fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) { let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; let n = 1usize << log2; + let nn = n * n; let tx_sz = tx_size_for(log2); let bd = self.bd; let src = &self.src[p]; - let mut res = vec![0i32; n * n]; + let TxScratch { res, coef, levels, deq, resid, recon } = sc; + let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]); for i in 0..n { let row = &src.row(y + i)[x..x + n]; for j in 0..n { res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32; } } - let mut coef = vec![0f32; n * n]; - forward_2d(&res, &mut coef, n, tx_type); + forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half let rnd = if intra { 0.38f32 } else { 0.30f32 }; - let mut levels = vec![0i32; n * n]; + levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { let q = if k == 0 { self.dc_q } else { self.ac_q }; @@ -829,23 +876,26 @@ impl<'a> Decider<'a> { } let zero_dist = self.sse(p, x, y, n, pred); if !nonzero { - return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None); + return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false); } - let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd); - let dist = self.sse(p, x, y, n, &recon); - let bits = estimate_bits(&levels, tx_sz); + let recon = &mut recon[..nn]; + self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd); + let dist = self.sse(p, x, y, n, recon); + let bits = estimate_bits(levels, tx_sz); if dist + self.lambda * bits >= zero_dist + self.lambda { - levels.iter_mut().for_each(|l| *l = 0); - return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None); + levels.fill(0); + return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false); } - (TxResult { levels, nonzero: true, dist, bits }, Some(recon)) + (TxEval { nonzero: true, dist, bits }, true) } - /// Dequantisation (7.12.3) and the inverse transform, added to the prediction. - fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec { + /// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is + /// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries. + fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) { + let ReconBuffers { deq, resid, recon } = bufs; let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 }; let lim = 1i64 << (7 + bd); - let mut deq = vec![0i32; n * n]; + deq.fill(0); for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() { if l == 0 { continue; @@ -855,10 +905,11 @@ impl<'a> Decider<'a> { let mag = (dq.abs() & 0xFF_FFFF) / denom; *d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32; } - let mut resid = vec![0i32; n * n]; - inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd); + inverse_2d(deq, resid, tx_sz, tx_type, bd); let max = (1i32 << bd) - 1; - pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect() + for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) { + *o = (p as i32 + r).clamp(0, max) as u16; + } } } diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index 6b1edfbe8..3fcf446bb 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -409,11 +409,13 @@ impl Encoder { }; let bd = self.cfg.bit_depth as u32; let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))]; + // one working copy of the luma plane for all candidate levels + let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; let mut eval = |lvl: u32| -> u64 { if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) { return e; } - let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; + planes[0].data.copy_from_slice(&rec[0].data); loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step); let e = sse(&planes[0]); cache.push((lvl, e)); diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index 7b354af1f..b899202f4 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -297,7 +297,8 @@ pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) /// The visible `w x h` area of a reference plane interpolated at the sub-sample phase /// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for -/// blocks wider and taller than 4. Used for motion search. +/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses. +#[cfg(test)] pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec { let kh = &SUBPEL_FILTERS[EIGHTTAP][fx]; let kv = &SUBPEL_FILTERS[EIGHTTAP][fy]; @@ -327,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz out } +/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the +/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to +/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )), +/// so the result equals three `subpel_plane` calls bit for bit. +pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] { + let k = &SUBPEL_FILTERS[EIGHTTAP][8]; + let max = (1i32 << bit_depth) - 1; + let (lx, ly) = (w as i32 - 1, h as i32 - 1); + // horizontal pass at phase 8: Round2( sum, 3 ) + let mut hp = vec![0i32; w * h]; + for y in 0..h { + let row = &refp.row(y)[..w]; + let out = &mut hp[y * w..y * w + w]; + let tap = |x: i32| -> i32 { + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32; + } + round2(s, 3) + }; + if w >= 8 { + for x in 0..3 { + out[x] = tap(x as i32); + } + for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) { + let seg = &row[x - 3..x + 5]; + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * seg[t] as i32; + } + *o = round2(s, 3); + } + for x in w - 4..w { + out[x] = tap(x as i32); + } + } else { + for (x, o) in out.iter_mut().enumerate() { + *o = tap(x as i32); + } + } + } + let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect(); + let mut p08 = vec![0u16; w * h]; + let mut p88 = vec![0u16; w * h]; + for y in 0..h { + let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize); + let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]); + for x in 0..w { + let (mut a, mut b) = (0i32, 0i32); + for t in 0..8 { + a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32; + b += k[t] as i32 * hp[rows[t] * w + x]; + } + o08[x] = round2(a, 7).clamp(0, max) as u16; + o88[x] = round2(b, 11).clamp(0, max) as u16; + } + } + [p80, p08, p88] +} + #[cfg(test)] mod tests { use super::*; @@ -361,6 +422,20 @@ mod tests { } } + #[test] + fn half_planes_match_subpel_plane() { + for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] { + let mut p = Plane::new(64, 64); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 1024) as u16; + } + let hp = half_planes(&p, w, h, 10); + for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() { + assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})"); + } + } + } + #[test] fn phase_zero_filters_are_impulses() { for f in SUBPEL_FILTERS.iter() { diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs index ed9bca1c4..a10bd2e9a 100644 --- a/crates/av1enc/src/writer.rs +++ b/crates/av1enc/src/writer.rs @@ -70,6 +70,8 @@ pub(crate) struct TileWriter<'a> { left_dc: [Vec; 3], /// LoopfilterTxSizes per plane (row stride mi_cols + 32). lf_tx: [Vec; 3], + /// Scratch for the levels coded so far in `coeffs`. + quant: Vec, // current block r: usize, c: usize, @@ -122,6 +124,7 @@ impl<'a> TileWriter<'a> { left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]), + quant: Vec::new(), r: 0, c: 0, bsize: 0, @@ -189,10 +192,10 @@ impl<'a> TileWriter<'a> { self.sw.symbol(partition, cdf); } else if has_cols || has_rows { assert!(split, "blocks crossing the frame edge are split"); - let pcdf: Vec = match bsl { - 2 => self.cdf.partition_w16[ctx].to_vec(), - 3 => self.cdf.partition_w32[ctx].to_vec(), - _ => self.cdf.partition_w64[ctx].to_vec(), + let pcdf: &[u16] = match bsl { + 2 => &self.cdf.partition_w16[ctx], + 3 => &self.cdf.partition_w32[ctx], + _ => &self.cdf.partition_w64[ctx], }; let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 }; let psum = if has_cols { @@ -493,7 +496,9 @@ impl<'a> TileWriter<'a> { let height = TX_HEIGHT[tx_sz] as usize; let width = 1usize << bwl; // Quant[] as the decoder sees it while parsing: the levels coded so far (capped) - let mut quant = vec![0i32; width * height]; + let mut quant = std::mem::take(&mut self.quant); + quant.clear(); + quant.resize(width * height, 0); for c in (0..eob).rev() { let pos = scan[c] as usize; let a = levels[pos].unsigned_abs().min(15) as i32; @@ -518,6 +523,7 @@ impl<'a> TileWriter<'a> { } quant[pos] = a; } + self.quant = quant; for c in 0..eob { let pos = scan[c] as usize; let v = levels[pos]; diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2a0dfc50e..71eb43296 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { return (0.0, 0.0, l); } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let denom = if l > 0.5 { 2.0 - max - min } else { max + min }; + // Over-range (HDR) or negative inputs can make the denominator zero or negative. + let s = if denom > 0.0 { d / denom } else { 1.0 }; let h = if max == r { (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { @@ -262,6 +264,20 @@ impl Label { mod tests { use super::*; + #[test] + fn hsl_round_trip_is_finite_for_out_of_range_colours() { + for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] { + let (h, s, l) = rgb_to_hsl(r, g, b); + assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}"); + let (r2, g2, b2) = hsl_to_rgb(h, s, l); + assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}"); + } + // A 32 bpc colour whose denominator stays positive still round-trips exactly. + let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5); + let (r, g, b) = hsl_to_rgb(h, s, l); + assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}"); + } + #[test] fn hsv_to_rgb_tiny_negative_hue_is_red() { let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 534da8061..916e162f6 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> { (angle > 0.0).then_some((angle, phase, n.clamp(2, 64))) } -fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { - let anchor = b.to_px(pr.v2("anchor")); - let pos = b.to_px(pr.v2("position")); +/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer), +/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)` +/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation. +pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 { + let anchor = to_px(pr.v2("anchor")); + let pos = to_px(pr.v2("position")); let sh = pr.f("scaleHeight"); let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + let axis = pr.f("skewAxis"); + let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan(); + let k = if k.is_finite() { k } else { 0.0 }; + let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis); Mat3::translate(vec2(pos.0, pos.1)) + * skew * Mat3::rotate_deg(pr.f("rotation")) - * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) * Mat3::translate(vec2(-anchor.0, -anchor.1)) } +fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { + transform_matrix_px(pr, |p| b.to_px(p)) +} + +/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by +/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds +/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a +/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs +/// a bounded allocation. +pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] { + const SIDE: f64 = 16_384.0; + const AREA: f64 = 16_777_216.0; + let (wf, hf) = (w as f64, h as f64); + let mut m = [0.0f64; 4]; + for mat in ms { + for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] { + let q = mat.apply(vec2(x, y)); + if q.x.is_finite() && q.y.is_finite() { + m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)]; + } + } + } + let m = m.map(|v| v.ceil().max(0.0)); + let fit = |len: f64, a: f64, b: f64| -> f64 { + let room = (SIDE - len).max(0.0); + if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 } + }; + let kx = fit(wf, m[0], m[2]); + let ky = fit(hf, m[1], m[3]); + let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k); + let k = if area(1.0) > AREA && area(1.0) > wf * hf { + // Shrink both margins by the same factor until the area fits. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..40 { + let mid = (lo + hi) / 2.0; + if area(mid) > AREA { hi = mid } else { lo = mid } + } + lo + } else { + 1.0 + }; + [m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32) +} + fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { - let anchor = b.to_px(ctx.params.v2("anchor")); - let pos = b.to_px(ctx.params.v2("position")); - let uniform = ctx.params.b("uniform"); - let sh = ctx.params.f("scaleHeight"); - let sw = if uniform { sh } else { ctx.params.f("scaleWidth") }; - let skew = ctx.params.f("skew"); - let skew_axis = ctx.params.f("skewAxis"); - let rot = ctx.params.f("rotation"); let opacity = ctx.params.f("opacity") as f32 / 100.0; let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear }; // Motion blur: the transform at Samples instants across the shutter, averaged (the @@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { && let Some(host) = ctx.env.host { let fd = 1.0 / ctx.fps(); - let mut acc = Image::new(b.img.width, b.img.height); - let mut count = 0.0f32; - for k in 0..n { - let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; - let Some(pr) = host.params_at(t) else { continue }; - let mut one = Image::new(b.img.width, b.img.height); - let op = pr.f("opacity") as f32 / 100.0; - effectcraft_raster::composite_warp( - &mut one, - &b.img, - &transform_matrix(&pr, &b), - &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, - ); - acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); - count += 1.0; - } - if count > 0.0 { + let samples: Vec = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect(); + if !samples.is_empty() { + if !ctx.adjustment { + let ms: Vec = samples.iter().map(|pr| transform_matrix(pr, &b)).collect(); + b.pad_sides(transform_grow(&ms, b.img.width, b.img.height)); + } + let mut acc = Image::new(b.img.width, b.img.height); + for pr in &samples { + let mut one = Image::new(b.img.width, b.img.height); + let op = pr.f("opacity") as f32 / 100.0; + effectcraft_raster::composite_warp( + &mut one, + &b.img, + &transform_matrix(pr, &b), + &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, + ); + acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + } + let count = samples.len() as f32; acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count)); b.img = acc; return b; } } - let m = Mat3::translate(vec2(pos.0, pos.1)) - * Mat3::rotate_deg(rot) - * Mat3::skew_deg(skew, skew_axis) - * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) - * Mat3::translate(vec2(-anchor.0, -anchor.1)); + if !ctx.adjustment { + let m = transform_matrix(ctx.params, &b); + b.pad_sides(transform_grow(&[m], b.img.width, b.img.height)); + } + let m = transform_matrix(ctx.params, &b); let mut out = Image::new(b.img.width, b.img.height); effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() }); b.img = out; @@ -576,6 +621,144 @@ mod tests { } } + /// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080 + /// comp with Transform. The skew applies after the rotation, and the result is drawn outside + /// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from + /// an edge; true = the solid covers it), half where the old order and clipping differed. + #[test] + fn transform_matches_after_effects() { + let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let p2 = |x: f64, y: f64| Value::Vec2([x, y]); + let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [ + ( + "723", + vec![ + ("anchor", p2(400.0, 200.0)), + ("position", p2(500.0, 300.0)), + ("uniform", Value::Bool(false)), + ("scaleHeight", num(80.0)), + ("scaleWidth", num(120.0)), + ("skew", num(10.0)), + ("skewAxis", num(20.0)), + ("rotation", num(30.0)), + ], + &[ + (1465, 954, true), + (1127, 821, true), + (1378, 846, true), + (1416, 878, true), + (1486, 850, true), + (492, 467, true), + (706, 239, true), + (1402, 1021, true), + (518, 440, true), + (1218, 882, true), + (454, 448, true), + (1473, 920, true), + (1048, 654, true), + (1050, 486, true), + (1060, 736, true), + (932, 804, true), + (1074, 562, true), + (653, 591, true), + (1353, 466, false), + (88, 1021, false), + (1855, 886, false), + (1796, 401, false), + (497, 913, false), + (1445, 515, false), + ], + ), + ( + "819", + vec![("skew", num(15.0)), ("skewAxis", num(60.0))], + &[ + (465, 367, true), + (554, 793, false), + (481, 529, true), + (1451, 599, true), + (428, 575, true), + (432, 600, true), + (463, 444, true), + (759, 803, false), + (445, 684, true), + (1411, 757, true), + (915, 784, false), + (480, 737, true), + (758, 620, true), + (975, 705, true), + (1289, 376, true), + (575, 420, true), + (1015, 507, true), + (1377, 448, true), + (1679, 171, false), + (194, 435, false), + (413, 159, false), + (327, 741, false), + (1824, 138, false), + (1093, 29, false), + ], + ), + ( + "820", + vec![ + ("skew", num(15.0)), + ("skewAxis", num(60.0)), + ("rotation", num(-25.0)), + ("uniform", Value::Bool(false)), + ("scaleWidth", num(130.0)), + ("scaleHeight", num(70.0)), + ("anchor", p2(100.0, 50.0)), + ], + &[ + (1674, 566, true), + (1425, 317, false), + (1788, 278, true), + (1530, 390, true), + (1531, 652, true), + (1589, 545, true), + (1890, 441, true), + (1724, 400, true), + (1657, 511, true), + (1895, 261, true), + (1697, 344, true), + (933, 840, true), + (1413, 467, true), + (1295, 691, true), + (1353, 548, true), + (1293, 548, true), + (1103, 551, true), + (973, 758, true), + (446, 484, false), + (1009, 420, false), + (144, 761, false), + (1812, 111, false), + (1241, 10, false), + (900, 854, false), + ], + ), + ]; + for (case, set, pts) in cases { + let o = run("ec.distort.transform", &set, solid.clone()); + for &(x, y, inside) in pts { + // Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer. + let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]); + let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 }; + assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}"); + } + } + // A huge scale stays within the buffer cap. + let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4])); + assert!(o.img.width <= 16_384 && o.img.height <= 16_384); + // A layer moved far right grows the buffer on that side only, within 16 Mpx. + let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4])); + assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows"); + assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01); + let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4])); + assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + } + #[test] fn transform_sampling_bicubic_differs_from_bilinear() { let im = img(32, 32); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index cc0aad515..204e651ec 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color_fx::{ pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; -pub use distort::transform_shutter; +pub use distort::{transform_grow, transform_matrix_px, transform_shutter}; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; @@ -86,7 +86,10 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; +pub use misc::{ + GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, + venetian_origin, +}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; @@ -104,7 +107,7 @@ pub use transition::place_layer; // effectcraft-gpu fx_noise. pub use keying2::{InnerOuterPlan, inner_outer_plan}; pub use noise::remove_grain_level; -pub use noise3::{FractalGpu, fractal_gpu}; +pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu}; pub use stylize3::{BumpLight, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; @@ -287,6 +290,27 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place. + pub fn pad_sides(&mut self, sides: [u32; 4]) { + let [l, t, r, b] = sides; + if l == 0 && t == 0 && r == 0 && b == 0 { + return; + } + let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b))) + else { + return; + }; + let mut out = Image::new(w, h); + let (sw, ow) = (self.img.width as usize, w as usize); + for y in 0..self.img.height as usize { + let at = (y + t as usize) * ow + l as usize; + let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue }; + dst.copy_from_slice(src); + } + self.img = out; + self.offset[0] += l as f64; + self.offset[1] += t as f64; + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b5976b761..35d0960e0 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay *g != before } +/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now: +/// `id` itself when the spec still has it, the new id of a renamed parameter +/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a +/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches. +pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> { + if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) { + return Some(ps.id); + } + if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) { + return Some(new); + } + if id.contains('/') { + return None; + } + spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id) +} + /// Bring an effect instance saved before project schema 2 into effect space (#227). /// /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on @@ -559,6 +576,21 @@ mod tests { assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + /// A saved parameter id maps to the parameter `upgrade_instance` moves it to. + #[test] + fn current_param_id_follows_renames_and_twirl_down_moves() { + let stab = find(crate::warp_stab::ID).unwrap(); + assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness")); + assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness")); + let ctc = find("ec.color.changetocolor").unwrap(); + assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue")); + // Radio Waves' old Velocity is the growth speed, not the new Velocity. + let waves = find("ec.generate.radiowaves").unwrap(); + assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion")); + assert_eq!(current_param_id(stab, "noSuchParam"), None); + assert_eq!(current_param_id(stab, "other/smoothness"), None); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 7468c6b84..e1f41954c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -142,6 +142,28 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases +/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's +/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2). +pub fn glow_sigma(radius: f64) -> f64 { + (radius * 0.384).max(0.5) +} + +/// Glow's bright pass for the original colours, per channel of the straight colour `v`: +/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey +/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10 +/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel, +/// its gain is the same at every threshold and radius, and channels below the threshold add +/// nothing (an orange solid glows red only). +pub fn glow_bright_channel(v: f32, thr: f32) -> f32 { + (GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0) +} + +/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals; +/// the GPU test `glow_constants_match_the_cpu` checks that they are these. +pub const GLOW_GAIN: f32 = 8.0; +pub const GLOW_KNEE: f32 = 0.0611; + /// Glow Operation options (blend modes the glow is applied with). pub const GLOW_OPERATIONS: [&str; 17] = [ "Normal", @@ -205,23 +227,31 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); + // Based on the colour channels, the glow's coverage is the layer's whole alpha, below + // the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha). + let ga = if alpha_based { k * a } else { a }; if let Some(map) = &arbitrary { let t = glow_ab_t(l, looping, loops, phase, mid); let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; + } else if alpha_based { + *px = [px[0] * k, px[1] * k, px[2] * k, ga]; } else { - *px = px.map(|v| v * k); + // Original colours: each channel of the straight colour against the threshold. + let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a); + *px = [c[0], c[1], c[2], ga]; } }); - let s = (radius / 2.0).max(0.5); + let s = glow_sigma(radius); let blurred = gaussian_blur(&bright, s * kx, s * ky, false); let mode = ctx.params.e("operation"); b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| { - let g = g.map(|v| v * intensity); + // Intensity scales the glow's colour; with the colour channels its coverage stays. + let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }]; match mode { // Behind: the glow shows only where the original is transparent. 1 => { @@ -525,22 +555,67 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). +fn norm_cdf(z: f64) -> f64 { + let x = z.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * x); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-x * x).exp(); + if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise +/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it, +/// Both (2) half each way. +pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) { + let span = done.clamp(0.0, 1.0) * 360.0; + match wipe { + 1 => (-span, span), + 2 => (-span / 2.0, span), + _ => (0.0, span), + } +} + +/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge +/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every +/// radius, σ = 8.0 px, mean error 0.0002 over the frame). +pub fn radial_wipe_sigma(feather: f64) -> f64 { + feather.max(0.0) * 0.4 +} + +/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from +/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the +/// wedge's nearer edge, through the feather's Gaussian. +pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 { + let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0); + let wiped = a < span; + let ray_dist = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + // Ray direction (sin, −cos) in y-down pixels. + let along = ux * s - uy * c; + if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) } + }; + let d = ray_dist(ray0).min(ray_dist(ray0 + span)); + let sd = if wiped { -d } else { d }; + if sigma < 1e-3 { + return if wiped { 0.0 } else { 1.0 }; + } + norm_cdf(sd / sigma) +} + fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let start = ctx.params.f("startAngle"); let c = b.to_px(ctx.params.v2("center")); - let dir = ctx.params.e("wipe"); - let feather = (ctx.params.f("feather")).max(0.01); + let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let full = done >= 1.0; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { - let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0); - // Both: the wipe opens in both directions from the start angle at once. - let (a, edge) = match dir { - 1 => (360.0 - a, done * 360.0), - 2 => (a.min(360.0 - a), done * 180.0), - _ => (a, done * 360.0), - }; - let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32; + let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 }; for ch in px.iter_mut() { *ch *= k; } @@ -549,16 +624,49 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` +/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). +/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 +/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255. +pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 { + let band = done * width; + if sigma < 1e-3 { + return if d < band { 1.0 } else { 0.0 }; + } + // Wider than a few blinds the stripes blur into an even veil. + if sigma > 8.0 * width { + return done.clamp(0.0, 1.0); + } + let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40); + let mut t = 0.0; + for k in -n..=n { + let u = d + k as f64 * width; + t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma); + } + t.clamp(0.0, 1.0) +} + +/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the +/// AE renders above; the stripes are measured from the layer's centre, not its corner). +pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] { + [layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6] +} + fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let ang = ctx.params.f("direction").to_radians(); let width = (ctx.params.f("width") * b.scale).max(1.0); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0; let (dx, dy) = (ang.cos(), ang.sin()); + let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size)); b.img.rows_mut().for_each(|(y, row)| { + let ry = (y as f64 + 0.5 - oy) * dy; for (x, px) in row.iter_mut().enumerate() { - let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width); - let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32; + let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width); + let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32; for c in px.iter_mut() { *c *= k; } @@ -807,6 +915,189 @@ mod tests { img } + /// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below + /// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the + /// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3 + /// and with Composite Original On Top or Behind; its width follows Glow Radius. + #[test] + fn glow_halo_matches_after_effects() { + let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]); + // AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge + // (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red. + let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)]; + let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)]; + for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])] + { + let set = [ + ("based", Value::Enum(1)), + ("threshold", num(60.0)), + ("radius", num(radius)), + ("intensity", num(intensity)), + ("operation", Value::Enum(operation)), + ]; + let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default()); + for &(d, red) in profile { + // Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0. + let (bx, by) = o.to_px([-d, 60.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + let ae = 1.0 - red / 255.0; + assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]); + assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}"); + } + } + } + + /// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200 + /// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue + /// channels of the render, where the red background adds nothing), and its alpha 0.353. + #[test] + fn glow_brightness_matches_after_effects() { + // (solid colour, channel, AE glow value 0..255) + let cases = [ + ([0.65, 0.65, 0.65], 1, 20.0), + ([0.8, 0.8, 0.8], 1, 47.0), + ([0.9, 0.9, 0.9], 1, 65.0), + ([1.0, 1.0, 1.0], 1, 83.0), + ([1.0, 0.5, 0.0], 1, 0.0), + ([0.2, 0.6, 1.0], 1, 11.0), + ([0.2, 0.6, 1.0], 2, 83.0), + ]; + for (c, ch, ae) in cases { + let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]); + let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))]; + let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0); + assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]); + } + // Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha. + let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}"); + } + + /// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540 + /// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward + /// transparency, letting the red layer below show through); on a full-frame horizontal ramp + /// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917. + #[test] + fn radial_blur_keeps_the_layer_edges_as_after_effects() { + let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))]; + let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default()); + for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] { + let p = o.img.get(x, y); + assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}"); + } + let mut ramp = effectcraft_raster::Image::new(1920, 1080); + ramp.rows_mut().for_each(|(_, row)| { + for (x, px) in row.iter_mut().enumerate() { + let v = (x as f32 + 0.5) / 1920.0; + *px = [v, v, v, 1.0]; + } + }); + let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default()); + for (x, ae) in [(2, 2.0), (1917, 253.0)] { + let p = o.img.get(x, 540); + assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}"); + } + } + + /// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on + /// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts + /// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2). + #[test] + fn venetian_blinds_match_after_effects() { + let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]); + let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))]; + let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default()); + let ae = [ + (200, 5.0), + (203, 47.0), + (206, 145.0), + (209, 228.0), + (212, 253.0), + (224, 255.0), + (236, 238.0), + (239, 168.0), + (242, 66.0), + (245, 10.0), + (251, 0.0), + (257, 2.0), + (260, 31.0), + ]; + for (x, a) in ae { + let got = o.img.get(x, 135)[3] * 255.0; + assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}"); + } + // No completion leaves the layer; full completion clears it; no feather is a hard edge. + let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))]; + let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]); + assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4)); + assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img); + let hard = run_fx( + "ec.transition.venetian", + &[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))], + img, + 0.0, + EffectEnv::default(), + ); + assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + // A feather far wider than the blinds is an even veil. + assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); + } + + /// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise, + /// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both + /// edges (8-bit captures of dark steps, so ±0.05). + #[test] + fn radial_wipe_feather_matches_after_effects() { + let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]); + let vals = [ + ("completion", Value::Scalar(40.0)), + ("startAngle", Value::Scalar(30.0)), + ("center", Value::Vec2([200.0, 150.0])), + ("wipe", Value::Enum(1)), + ("feather", Value::Scalar(20.0)), + ]; + let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default()); + let ae = [ + ((-50, 34), 0.922), + ((-52, 30), 0.797), + ((-54, 26), 0.609), + ((-55, 24), 0.5), + ((-56, 22), 0.406), + ((-57, 19), 0.266), + ((-58, 15), 0.125), + ((-104, 60), 0.951), + ((-108, 53), 0.732), + ((-111, 45), 0.341), + ((-114, 37), 0.062), + ((22, -56), 0.156), + ((26, -54), 0.312), + ((30, -52), 0.531), + ((34, -50), 0.729), + ((37, -47), 0.875), + ((53, -108), 0.177), + ((67, -99), 0.875), + ]; + for ((dx, dy), a) in ae { + let got = o.img.get(200 + dx, 150 + dy)[3]; + assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}"); + } + // 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge. + let at = |c: f64, f: f64| { + let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))]; + run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img + }; + assert_eq!(at(0.0, 20.0), img); + assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0)); + assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index bf1f05ed6..49f200553 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1; /// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation). pub const NOISE_SOFT_LINEAR: u32 = 2; +/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026 +/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching +/// correlation length), not 100 px. +pub const NOISE_CELL: f64 = 2.0 / 3.0; + fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } } @@ -236,7 +241,7 @@ impl Fractal { overflow: pr.e("overflow"), invert: pr.b("invert"), rot: rot.sin_cos(), - size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32], + size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32], origin: [ox as f32, oy as f32], center: [cx as f32, cy as f32], perspective: pr.b("transform/perspectiveOffset"), @@ -535,14 +540,34 @@ mod tests { #[test] fn noise_types_differ_and_block_is_piecewise_constant() { - let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); - // Block noise: neighbours inside one 10 px cell share a value. + let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]); + // Block noise: neighbours inside one 10 px cell (Scale 15) share a value. assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); } + /// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every + /// 66.67 px along a row. + #[test] + fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() { + let img = run_fx( + "ec.noise.fractal", + &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))], + Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]), + 0.0, + EffectEnv::default(), + ) + .img; + let changes: Vec = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect(); + assert!(changes.len() >= 4, "{changes:?}"); + for w in changes.windows(2) { + let d = (w[1] - w[0]) as f64; + assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}"); + } + } + #[test] fn scale_width_height_apply_without_uniform_scaling() { let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))]; diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index e61357c9f..6804ea2c7 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool /// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate, /// the mode and the model. The prompt is in each frame's key. pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 { + seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325; fnv(&mut h, ID.as_bytes()); fnv(&mut h, s(params, INPUT_KEY).as_bytes()); @@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn fnv(&mut h, &v.to_bits().to_le_bytes()); } fnv(&mut h, &[tracks(params) as u8]); - fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + fnv(&mut h, model.as_bytes()); h } @@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64 /// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, /// model), the prompt, the tracked span and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { - let mut h = seed(params, layer_size, fps, model); + frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { + let mut h = seed_of(params, layer_size, fps, model); let base = reference_frame(params); - if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) { fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); } for v in [base, reach(params)[0], reach(params)[1]] { @@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool { } /// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still -/// match ([`frozen_key`]) and `f` is one of them. +/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model` +/// or under the model that froze them (which this session may not have loaded, e.g. a fresh +/// headless render): frozen mattes need no segmentation. pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { let j = s(params, FROZEN); if j.is_empty() { @@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: } let fc = frozen_cache(j)?; if fc.key != frozen_key(params, layer_size, fps, model) { - return None; + let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id)); + if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) { + return None; + } } let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; @@ -705,4 +720,28 @@ mod tests { p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } + + /// Mattes frozen with a model stay usable in a session where that model is not loaded (a + /// fresh headless render): they are used as stored, not re-segmented with another model. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut p = params(&[ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([10.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 70.0])), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into())); + let stub = Stub::default(); + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some()); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + } } diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 403c325cd..e28f9a4fa 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { let rr = (2.0 / 3.0 * y) / r; // Cube rounding. let (cx, cz) = (q, rr); - let cy = -cx - cz; + let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r; let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); if dx > dy && dx > dz { diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index 226654a04..b49765c28 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -223,6 +223,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { p } +/// Validate and cache the default stabilization plan after analysis. Use the serialized +/// analysis so the cached plan has exactly the same inputs as the first render, including +/// any floating-point round trip through JSON. Other settings still get their own plan. +pub fn prepare_default_plan(json: &str) -> bool { + let Some((key, a)) = analysis_cached(json) else { return false }; + if a.is_empty() { + return false; + } + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() }); + p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite())) +} + /// The stored analysis of an instance (`None` when not analysed). pub fn analysis(params: &Params) -> Option> { let j = s(params, ANALYSIS); @@ -601,6 +614,42 @@ mod tests { assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data); } + #[test] + fn prepared_default_plan_preserves_first_render_and_is_reused() { + let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 0.012345678901234567; + a.tracks = (0..24) + .map(|id| effectcraft_track::camtrack::Track2D { + id, + start: 0, + pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(), + }) + .collect(); + let json = a.to_json(); + let ps = params(&json, 2, 0); + let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() }; + let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 }; + // Original flow: validate, discard the plan, then render from the stored analysis. + let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() }); + let before = render(&ctx, input.clone()); + let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0))); + plans().lock().unwrap().remove(&key); + assert!(prepare_default_plan(&json)); + let prepared = plans().lock().unwrap().get(&key).unwrap().clone(); + assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan"); + let after = render(&ctx, input); + let (_, _, rendered) = plan_at(&ps, a.start).unwrap(); + assert!(Arc::ptr_eq(&prepared, &rendered)); + assert_eq!(before.offset, after.offset); + assert_eq!(before.scale.to_bits(), after.scale.to_bits()); + for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + assert!(!prepare_default_plan("invalid")); + assert!(!prepare_default_plan(&WarpAnalysis::default().to_json())); + } + #[test] fn nested_param_lookup() { let mut p = Params::default(); diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 5472d4e8c..71ee45ee6 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -4,7 +4,7 @@ //! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out //! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the -//! stabilization plan once to validate the result. When the job finishes, the analysis is +//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that //! change those clear the analysis (`invalidate`, run after every edit) and queue the effect @@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; -use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis}; +use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; use crate::tracking::lock; @@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) { let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let a = an.finish(lt0, fd); - // Step 2: derive a plan once (validates the motion and warms nothing else up). - let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() }); - if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) { + // Plan from the stored JSON, exactly as rendering does, and retain it for the first render. + if !warp_stab::prepare_default_plan(&a.to_json()) { return fail("the stabilization could not be computed"); } *lock(&shared.result) = Some(a); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index bceef85fb..cba6df6ef 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -17,6 +17,8 @@ pub mod content_fill; mod create; mod edit; mod effect; +pub use effect::CryptoProbeJob; +pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe}; pub mod essential; pub mod expr_tools; mod file; diff --git a/crates/engine/src/cmd/align_data.rs b/crates/engine/src/cmd/align_data.rs index 38d9e7841..99ed3d113 100644 --- a/crates/engine/src/cmd/align_data.rs +++ b/crates/engine/src/cmd/align_data.rs @@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option { } // ISO 8601: date [T| ] time [zone]. if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { - let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); + let (y, m, d) = (s.get(0..4)?.parse::().ok()?, s.get(5..7)?.parse::().ok()?, s.get(8..10)?.parse::().ok()?); let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0; - let rest = s[10..].trim_start_matches(['T', 't', ' ']); + let rest = s.get(10..)?.trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) { @@ -229,6 +229,12 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn multibyte_date_is_rejected() { + assert_eq!(parse_stamp(&json!("2026-05-0é"), 25.0), None); + assert_eq!(parse_stamp(&json!("2026-05-01é"), 25.0), None); + } + #[test] fn parses_timestamps() { let p = |v: Value| parse_stamp(&v, 25.0).unwrap(); diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 1bdf3fed7..ddf6a79e5 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -88,6 +88,21 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) } +/// Rewrite an effect property path in a preset (`effects/[#n]/`) to the current +/// parameter id when it names a parameter that the effect has since renamed or moved into a +/// twirl-down group. Any other path comes back unchanged. +fn upgrade_effect_path(path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let effect = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m, + _ => seg, + }; + match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) { + Some(id) if id != tail => format!("effects/{seg}/{id}"), + _ => path.to_string(), + } +} + fn apply_preset(s: &mut Session, p: &Value) -> Result { let doc: Value = match (p.get("preset"), str_p(p, "path")) { (Some(v), _) => v.clone(), @@ -117,7 +132,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { .map(|a| { a.iter() .filter_map(|v| { - let path = v["path"].as_str()?.to_string(); + let path = upgrade_effect_path(v["path"].as_str()?); let value: KV = serde_json::from_value(v["value"].clone()).ok()?; let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); @@ -130,16 +145,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { }) .unwrap_or_default(); let t = s.time(); + // Effect bounds per layer size the point defaults of parameters added since the preset was saved. + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sizes: BTreeMap = + ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect(); let mut applied = 0; let mut skipped = vec![]; s.edit("Apply Animation Preset", None, |proj, _| { let mut next = proj.next_id; for lid in &ids { + let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]); let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(fx) = l.props.sub_mut("effects") { for g in &effects { let mut g = g.clone(); + // Effects saved by an earlier version get the upgrade a project gets on open. + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + if let Some(spec) = spec { + effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size); + } g.reassign_uids(&mut next); next += 1; fx.children.push(Node::Group(g)); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d0dbabb5b..0130aff70 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -847,10 +847,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result { "layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(), "bytes": st.bytes, "maxBytes": st.max_bytes, + "limitBytes": st.limit_bytes, "hits": st.hits, "misses": st.misses, "writes": st.writes, "evictions": st.evictions, + "failed": st.failed, })) } diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 4f263b6fb..0a1354fd6 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { /// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. struct CryptoHit { at: (effectcraft_project::ItemId, LayerId, Tick), + probe: std::sync::Arc, aux: std::sync::Arc, layer: String, hash: u32, @@ -210,13 +211,135 @@ impl CryptoHit { } } -/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for -/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point -/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` -/// (the viewer's resolution). -fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { +/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect +/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render +/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a +/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]). +pub struct CryptoProbe { + key: ProbeKey, + grid: std::result::Result, +} + +#[derive(Clone)] +struct ProbeGrid { + aux: std::sync::Arc, + layer: String, + scale: f64, + offset: [f64; 2], +} + +/// What a probe depends on: the project revision, the effect (and its index), the frame, the +/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows). +#[derive(Clone, Copy, PartialEq, Debug)] +struct ProbeKey { + revision: u64, + at: (effectcraft_project::ItemId, LayerId, Uid, usize), + frame: ProbeFrame, + scale: f64, + prefs: (bool, bool), +} + +/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect +/// don't change over time, its source frame: a still has one, held or remapped frames share +/// theirs. Otherwise the comp time. +#[derive(Clone, Copy, PartialEq, Debug)] +enum ProbeFrame { + Source(i64), + Comp(Tick), +} + +/// The session's kept probe, and the generation of the latest request: a build stores its +/// result only while no newer request or edit came since. +#[derive(Default)] +pub struct CryptoProbeSlot { + probe: Option>, + want: Option, + generation: u64, + /// Jobs run (each on its thread), and those that built (not cancelled before they began). + jobs: u64, + builds: u64, +} + +impl CryptoProbeSlot { + /// Drop the kept probe and cancel builds in flight. + pub(crate) fn clear(&mut self) { + (self.probe, self.want) = (None, None); + self.generation += 1; + } + pub(crate) fn kept(&self) -> bool { + self.probe.is_some() + } + pub(crate) fn builds(&self) -> (u64, u64) { + (self.jobs, self.builds) + } +} + +/// A probe built off the UI thread into the session's slot. +pub type CryptoProbeJob = Box; + +const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"; + +impl CryptoProbe { + /// `None` when `live` turns false between its stages (a newer request or an edit since): + /// a cancelled build stops early and lets go of what it read. + fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option { + let (cid, lid, _, pos) = key.at; + let build = || -> std::result::Result, String> { + let comp = r.project.comp(cid).ok_or("no composition")?; + let layer = comp.layer(lid).ok_or("no such layer")?; + let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; + let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); + let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); + let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; + if !live() { + return Ok(None); + } + let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?; + // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale + // up, proxies have their own size, earlier effects can pad it), rendered and cached. + if !live() { + return Ok(None); + } + let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?; + Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset })) + }; + // On another thread no command guard runs: a panic becomes the probe's error. + let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string())); + Some(CryptoProbe { key, grid: grid.transpose()? }) + } +} + +/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression). +fn moves(g: &PropGroup, skip: Option) -> bool { + g.children.iter().any(|c| match c { + effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()), + effectcraft_project::Node::Group(g) => moves(g, skip), + }) +} + +/// [`ProbeFrame`] of the effect at index `pos` of `layer`. +fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame { + let comp = ProbeFrame::Comp(ctx.time); + let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp }; + let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp }; + let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1)); + let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time.")); + let still = f.kind == effectcraft_project::FootageKind::Still; + let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off; + // The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it. + let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None }; + if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) { + return comp; + } + ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 }) +} + +/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp +/// time, its layer, group and Selection property. +#[allow(clippy::type_complexity)] +fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> { let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; - let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; let scale = f_p(p, "scale").unwrap_or(1.0); if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) { return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1")); @@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; - let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; - let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); - let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }); - let aux = r.layer_aux(&ctx, layer); - let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); - let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { - return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); + let time = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let frame = probe_frame(&ctx, layer, pos); + let o = s.render_opts(Default::default()); + let prefs = (o.nested_switches, o.draft_shadows); + Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop)) +} + +/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken +/// before an undo, redo or history jump). +pub(crate) fn kept_crypto_probe(s: &Session) -> Option> { + s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision) +} + +/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the +/// probe from before, still answers when only Cryptomatte Selections changed, which the probe +/// doesn't read (undoing a pick, as the pick itself keeps it). +pub(crate) fn keep_crypto_probe(s: &Session, kept: Option>, before: &effectcraft_project::Project) { + let Some(p) = kept else { return }; + if !only_crypto_selections_differ(before, &s.project) { + return; + } + if let Ok(mut slot) = s.crypto_probe.lock() + && slot.probe.is_none() + { + slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() })); + } +} + +/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects. +fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool { + let mut x = a.clone(); + for (cid, it) in x.items.iter_mut() { + let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue }; + let sels: Vec<(LayerId, Uid)> = c + .layers + .iter() + .flat_map(|l| { + l.effects() + .into_iter() + .flat_map(|fx| fx.groups()) + .filter(|g| g.match_id == "ec.3d.cryptomatte") + .filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid))) + }) + .collect(); + if sels.is_empty() { + continue; + } + let Some(bc) = b.comp(*cid) else { return false }; + let c = std::sync::Arc::make_mut(c); + for (lid, uid) in sels { + let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false }; + if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) { + *dst = src.clone(); + } + } + } + x.same_content(b) +} + +/// The session's probe when it has `key`'s. +fn cached_probe(s: &Session, key: ProbeKey) -> Option> { + s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key) +} + +/// [`Session::crypto_probe_ready`]. +pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result { + let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?; + Ok(cached_probe(s, key).is_some()) +} + +/// [`Session::crypto_probe_job`]. +pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result> { + let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?; + let generation = { + let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?; + if slot.probe.as_ref().is_some_and(|p| p.key == key) { + return Ok(None); + } + // A request for another probe cancels the one in flight. + if slot.want != Some(key) { + (slot.want, slot.generation) = (Some(key), slot.generation + 1); + } + slot.generation }; - // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, - // proxies have their own size, earlier effects can pad it), rendered and cached. - let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (project, footage, expr, cache, accel, slot) = + (s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone()); + let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() }); + // The slot while this is still the newest request. + fn current(slot: &std::sync::Mutex, generation: u64) -> Option> { + slot.lock().ok().filter(|s| s.generation == generation) + } + Ok(Some(Box::new(move || { + match slot.lock() { + Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1), + Ok(mut s) => return s.jobs += 1, + Err(_) => return, + } + let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts); + r.expr = expr.as_deref(); + r.cache = Some(&cache); + r.accel = accel.as_deref(); + // Checked between its stages too: a superseded build stops at the next one. + let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return }; + let probe = std::sync::Arc::new(probe); + // Only the newest request's result, and never over a newer probe. + if let Some(mut s) = current(&slot, generation) + && s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision) + { + s.probe = Some(probe); + } + }))) +} + +/// What a pick finds at a point. +enum Hit { + /// `probeOnly` and the probe isn't built yet. + Pending, + Nothing, + Id(CryptoHit), +} + +/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for +/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point +/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` +/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`. +fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { + let (key, time, layer, g, prop) = probe_key(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let probe = match cached_probe(s, key) { + Some(probe) => probe, + None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending), + None => { + let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() }); + if let Ok(mut slot) = s.crypto_probe.lock() { + slot.clear(); + slot.builds += 1; + } + // (Never cancelled here: nothing else runs on this thread.) + let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) }; + if let Ok(mut slot) = s.crypto_probe.lock() { + slot.probe = Some(probe.clone()); + } + probe + } + }; + let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?; let (bs, o) = (b.scale, b.offset); // The centre of its pixel under the point, at the aux point the effect reads there. let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]); - let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; - let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); - Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) + let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; + let (cid, lid, ..) = key.at; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); + let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); + let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); + Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) } /// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per -/// frame renders the effect's input to learn its pixel grid (cached afterwards). +/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly` +/// it answers `{pending: true}` instead. fn crypto_at(s: &mut Session, p: &Value) -> Result { - Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) + Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? { + Hit::Pending => json!({"pending": true}), + Hit::Nothing => Value::Null, + Hit::Id(h) => h.reply(&h.selection), + }) } /// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it @@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result { "remove" => PickMode::Remove, m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))), }; - let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?; + let h = match crypto_hit(s, p, cmd)? { + Hit::Id(h) => h, + Hit::Pending => return Ok(json!({"pending": true, "changed": false})), + Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")), + }; if h.driven { return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); } @@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result { if changed { let (cid, lid, t) = h.at; s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?; + // The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not + // "Change Property" like any other edit. (A changed Selection is always a new step.) + if let Some(step) = s.history.undo.last_mut() { + step.0 = "Pick Cryptomatte".into(); + } + // Only the Selection changed, which the probe doesn't read: the next hover keeps it. + // (The edit dropped it; a probe built since is newer.) + if let Ok(mut slot) = s.crypto_probe.lock() + && slot.probe.is_none() + { + let key = ProbeKey { revision: s.revision, ..h.probe.key }; + slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() })); + } } let mut out = h.reply(&sel); out["selection"] = json!(sel); @@ -732,14 +1017,14 @@ pub fn specs() -> Vec { "Pick Cryptomatte", [], None, - "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point", + "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point", has_layers, pick_crypto ), crate::query!( "effect.cryptomatteAt", "Cryptomatte At", - "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point", + "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point", crypto_at ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 74a86e1d8..1e57d4b5c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's /// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { + // File ▸ Scripts ▸ Recent Script Files ▸