From 9f0cfb3373bb6c1f730333a1660456b41290b824 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 20:49:29 -0400 Subject: [PATCH 01/32] M0.1: cargo xtask (layers, assets, wasm, ci); attribute ArtCraft brand marks Layer table matches the real dependency graph; wasm check passes for all L0-L4 crates and the egui UI. Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 2 + AGENTS.md | 5 + ATTRIBUTION.md | 16 ++ Cargo.toml | 2 +- .../brand/artcraft-logo-white.png.attribution | 8 + .../brand/artcraft-logo-white.svg.attribution | 8 + docs/brand/artcraft-logo.png.attribution | 8 + docs/brand/artcraft-logo.svg.attribution | 8 + .../brand/artcraft-mark-black.png.attribution | 8 + .../brand/artcraft-mark-black.svg.attribution | 8 + docs/brand/artcraft-mark.png.attribution | 8 + docs/brand/artcraft-mark.svg.attribution | 8 + xtask/Cargo.toml | 9 + xtask/src/main.rs | 251 ++++++++++++++++++ 14 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .cargo/config.toml create mode 100644 docs/brand/artcraft-logo-white.png.attribution create mode 100644 docs/brand/artcraft-logo-white.svg.attribution create mode 100644 docs/brand/artcraft-logo.png.attribution create mode 100644 docs/brand/artcraft-logo.svg.attribution create mode 100644 docs/brand/artcraft-mark-black.png.attribution create mode 100644 docs/brand/artcraft-mark-black.svg.attribution create mode 100644 docs/brand/artcraft-mark.png.attribution create mode 100644 docs/brand/artcraft-mark.svg.attribution create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..9741f113e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --quiet --package xtask --" diff --git a/AGENTS.md b/AGENTS.md index 11de27814..ff6e109be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,11 @@ reproduce an image (for example, point lists traced from someone else's icon). `crates/engine/src/demo.rs`, generators in `crates/effects`) are original work under the project licence and are listed in `ATTRIBUTION.md`. 7. **When in doubt, leave it out** and draw or generate it yourself. +8. **The one exception: first-party ArtCraft brand marks.** The ArtCraft name and logos in `docs/brand/` + (supplied by the ArtCraft team) are trademarks, all rights reserved, used with permission. They still + need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other + non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub + and other logos stay out; draw a generic icon instead). ## 2. Clean-room code diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9a61c820b..8e6e35339 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -21,3 +21,19 @@ iconography, images or other Adobe assets; open licences only; every asset attri - UI icons drawn in code: `crates/ui-egui/src/icons.rs`. - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. + +## First-party brand marks + +The ArtCraft name and logo are trademarks of the ArtCraft team, used in EffectCraft with permission. They are not +openly licensed and are not covered by the project licence; forks must remove or replace them (AGENTS.md §1.8). + +| File | Author | Source | Licence | +|---|---|---|---| +| `docs/brand/artcraft-logo-white.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-logo.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-logo.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-mark-black.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-mark.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | +| `docs/brand/artcraft-mark.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) | diff --git a/Cargo.toml b/Cargo.toml index 4165cfef2..fe25634f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "apps/*"] +members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] diff --git a/docs/brand/artcraft-logo-white.png.attribution b/docs/brand/artcraft-logo-white.png.attribution new file mode 100644 index 000000000..985285400 --- /dev/null +++ b/docs/brand/artcraft-logo-white.png.attribution @@ -0,0 +1,8 @@ +asset: artcraft-logo-white.png +title: ArtCraft wordmark, light ink for dark backgrounds +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-logo-white.svg.attribution b/docs/brand/artcraft-logo-white.svg.attribution new file mode 100644 index 000000000..e526cefc2 --- /dev/null +++ b/docs/brand/artcraft-logo-white.svg.attribution @@ -0,0 +1,8 @@ +asset: artcraft-logo-white.svg +title: ArtCraft wordmark, light ink for dark backgrounds +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-logo.png.attribution b/docs/brand/artcraft-logo.png.attribution new file mode 100644 index 000000000..80558c89d --- /dev/null +++ b/docs/brand/artcraft-logo.png.attribution @@ -0,0 +1,8 @@ +asset: artcraft-logo.png +title: ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-logo.svg.attribution b/docs/brand/artcraft-logo.svg.attribution new file mode 100644 index 000000000..7f03e5adb --- /dev/null +++ b/docs/brand/artcraft-logo.svg.attribution @@ -0,0 +1,8 @@ +asset: artcraft-logo.svg +title: ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-mark-black.png.attribution b/docs/brand/artcraft-mark-black.png.attribution new file mode 100644 index 000000000..bdff94058 --- /dev/null +++ b/docs/brand/artcraft-mark-black.png.attribution @@ -0,0 +1,8 @@ +asset: artcraft-mark-black.png +title: ArtCraft symbol ("A" mark), black +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-mark-black.svg.attribution b/docs/brand/artcraft-mark-black.svg.attribution new file mode 100644 index 000000000..f0f740870 --- /dev/null +++ b/docs/brand/artcraft-mark-black.svg.attribution @@ -0,0 +1,8 @@ +asset: artcraft-mark-black.svg +title: ArtCraft symbol ("A" mark), black +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-mark.png.attribution b/docs/brand/artcraft-mark.png.attribution new file mode 100644 index 000000000..5f1953a43 --- /dev/null +++ b/docs/brand/artcraft-mark.png.attribution @@ -0,0 +1,8 @@ +asset: artcraft-mark.png +title: ArtCraft symbol ("A" mark), blue +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/docs/brand/artcraft-mark.svg.attribution b/docs/brand/artcraft-mark.svg.attribution new file mode 100644 index 000000000..58c2ae4d4 --- /dev/null +++ b/docs/brand/artcraft-mark.svg.attribution @@ -0,0 +1,8 @@ +asset: artcraft-mark.svg +title: ArtCraft symbol ("A" mark), blue +author: ArtCraft team (Storyteller) +source: original work (first-party ArtCraft brand asset supplied by the ArtCraft team) +license: LicenseRef-ArtCraft-Trademark — ArtCraft name and logo are trademarks of the ArtCraft team, all rights reserved; used in EffectCraft with permission. Not covered by the project's MIT OR Apache-2.0 licence. +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: First-party ArtCraft brand mark kept in docs/brand for the README and brand use: the one exception to the open-licence rule (AGENTS.md §1.8). Forks and redistributions must remove or replace it unless they have permission. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 000000000..df7dfa877 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 000000000..92d6d64bf --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,251 @@ +//! Workspace automation: `cargo xtask `. +//! +//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges, +//! listed same-layer edges, no UI/OS crates below L5). +//! - `wasm`: `cargo check --target wasm32-unknown-unknown` for every crate in L0–L4 and the egui UI. +//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete +//! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). +//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. + +use std::process::{Command, ExitCode}; + +use serde_json::Value; + +/// (crate name without the `effectcraft-` prefix, layer). See `docs/architecture.md` §1. +const LAYERS: &[(&str, u8)] = &[ + ("time", 0), + ("geom", 0), + ("color", 0), + ("testkit", 0), + ("raster", 1), + ("keyframe", 1), + ("path", 1), + ("project", 2), + ("text", 2), + ("effects", 2), + ("render", 3), + ("media", 3), + ("expr", 3), + ("export", 3), + ("gpu", 3), + ("lottie", 3), + ("format", 3), + ("engine", 4), + ("host", 4), + ("ui-egui", 5), + ("automation", 5), + ("effectcraft", 6), + ("cli", 6), + ("web", 6), +]; + +/// Allowed same-layer edges (from, to). +const SAME_LAYER: &[(&str, &str)] = &[ + ("path", "keyframe"), + ("path", "raster"), + ("text", "path"), + ("effects", "project"), + ("effects", "text"), + ("effects", "path"), + ("media", "render"), + ("expr", "render"), + ("export", "render"), + ("export", "media"), + ("gpu", "render"), + ("lottie", "format"), + ("host", "engine"), + ("cli", "effectcraft"), +]; + +/// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus). +const UI_ONLY: &[&str] = &["egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; + +fn short(name: &str) -> &str { + name.strip_prefix("effectcraft-").unwrap_or(name) +} + +fn layer_of(name: &str) -> Option { + LAYERS.iter().find(|(n, _)| *n == short(name)).map(|(_, l)| *l) +} + +fn metadata() -> Result { + let out = Command::new(env!("CARGO")).args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into()); + } + serde_json::from_slice(&out.stdout).map_err(|e| e.to_string()) +} + +fn workspace_crates(md: &Value) -> Vec<(String, Vec)> { + md["packages"] + .as_array() + .into_iter() + .flatten() + .filter(|p| p["name"] != "xtask") + .map(|p| { + let deps = p["dependencies"] + .as_array() + .into_iter() + .flatten() + .filter(|d| d["kind"].is_null() || d["kind"] == "build") + .filter_map(|d| d["name"].as_str().map(str::to_string)) + .collect(); + (p["name"].as_str().unwrap_or_default().to_string(), deps) + }) + .collect() +} + +fn layers() -> Result<(), String> { + let md = metadata()?; + let mut errors = Vec::new(); + for (name, deps) in workspace_crates(&md) { + let Some(l) = layer_of(&name) else { + errors.push(format!("{name}: not assigned a layer (add it to xtask LAYERS and docs/architecture.md §1)")); + continue; + }; + for d in &deps { + if l < 5 && UI_ONLY.contains(&d.as_str()) { + errors.push(format!("{name} (L{l}) depends on UI/OS crate `{d}`")); + } + if !d.starts_with("effectcraft-") { + continue; + } + let Some(dl) = layer_of(d) else { continue }; + let (a, b) = (short(&name), short(d)); + if dl > l { + errors.push(format!("{a} (L{l}) depends upward on {b} (L{dl})")); + } else if dl == l && l > 0 && !SAME_LAYER.contains(&(a, b)) { + errors.push(format!("{a} → {b}: same-layer edge (L{l}) not in the allowed list")); + } + } + } + if errors.is_empty() { + println!("layers: ok"); + Ok(()) + } else { + Err(errors.join("\n")) + } +} + +/// File extensions that count as assets (AGENTS.md §1). +const ASSET_EXT: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "icns", "bmp", "tif", "tiff", "heic", "avif", "exr", "ttf", "otf", "ttc", "woff", "woff2", "cube", + "3dl", "lut", "look", "wav", "mp3", "aac", "flac", "ogg", "opus", "m4a", "aif", "aiff", "mp4", "mov", "m4v", "mkv", "webm", "avi", "mxf", "psd", "ai", + "eps", "pdf", "prproj", "ffx", "prfpset", "mogrt", "aep", +]; + +/// Sidecar fields that must be present and non-empty. +const REQUIRED_FIELDS: &[&str] = &["asset", "title", "author", "source", "license", "added"]; + +fn repo_files() -> Result, String> { + let out = Command::new("git").args(["ls-files", "--cached", "--others", "--exclude-standard"]).output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into()); + } + Ok(String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).filter(|f| std::path::Path::new(f).exists()).collect()) +} + +fn is_asset(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ASSET_EXT.contains(&e.to_ascii_lowercase().as_str())) +} + +fn assets() -> Result<(), String> { + let files = repo_files()?; + let index = std::fs::read_to_string("ATTRIBUTION.md").map_err(|e| format!("ATTRIBUTION.md: {e}"))?; + let mut errors = Vec::new(); + let mut n = 0; + for f in files.iter().filter(|f| is_asset(f)) { + n += 1; + let lower = f.to_ascii_lowercase(); + if lower.contains("adobe") || lower.contains("aftereffects") || lower.contains("after-effects") { + errors.push(format!("{f}: asset paths must not reference Adobe/After Effects (AGENTS.md §1)")); + } + let side = format!("{f}.attribution"); + match std::fs::read_to_string(&side) { + Err(_) => errors.push(format!("{f}: missing attribution sidecar {side}")), + Ok(text) => { + for field in REQUIRED_FIELDS { + let ok = text.lines().any(|l| l.split_once(':').is_some_and(|(k, v)| k.trim() == *field && !v.trim().is_empty())); + if !ok { + errors.push(format!("{side}: field `{field}` missing or empty")); + } + } + let lic = text.lines().find_map(|l| l.split_once(':').filter(|(k, _)| k.trim() == "license").map(|(_, v)| v.trim().to_ascii_lowercase())); + if lic.is_some_and(|l| l.contains("-nc") || l.contains("-nd") || l.contains("adobe") || l.contains("proprietary")) { + errors.push(format!("{side}: licence not allowed (no NC/ND, Adobe or proprietary licences)")); + } + } + } + if !index.contains(&format!("`{f}`")) { + errors.push(format!("{f}: not listed in ATTRIBUTION.md")); + } + } + for f in files.iter().filter(|f| f.ends_with(".attribution")) { + let asset = f.trim_end_matches(".attribution"); + if !std::path::Path::new(asset).exists() { + errors.push(format!("{f}: sidecar for a file that does not exist")); + } + } + if errors.is_empty() { + println!("assets: ok ({n} assets attributed)"); + Ok(()) + } else { + Err(errors.join("\n")) + } +} + +fn run(cmd: &mut Command) -> Result<(), String> { + eprintln!("$ {cmd:?}"); + let st = cmd.status().map_err(|e| e.to_string())?; + if st.success() { Ok(()) } else { Err(format!("failed: {cmd:?}")) } +} + +/// Crates above L4 that must also build for the web. +const WEB_CRATES: &[&str] = &["effectcraft-ui-egui"]; + +fn wasm() -> Result<(), String> { + let md = metadata()?; + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["check", "--target", "wasm32-unknown-unknown"]); + let mut n = 0; + for (name, _) in workspace_crates(&md) { + if layer_of(&name).is_some_and(|l| l <= 4) || WEB_CRATES.contains(&name.as_str()) { + cmd.args(["-p", &name]); + n += 1; + } + } + if n == 0 { + return Ok(()); + } + run(&mut cmd)?; + println!("wasm: ok ({n} crates)"); + Ok(()) +} + +fn ci() -> Result<(), String> { + let cargo = env!("CARGO"); + run(Command::new(cargo).args(["fmt", "--check"]))?; + run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?; + run(Command::new(cargo).args(["test", "--workspace", "--release"]))?; + layers()?; + assets()?; + wasm() +} + +fn main() -> ExitCode { + let task = std::env::args().nth(1).unwrap_or_default(); + let r = match task.as_str() { + "layers" => layers(), + "wasm" => wasm(), + "assets" => assets(), + "ci" => ci(), + _ => Err("usage: cargo xtask ".into()), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} From 214345df137dec07adaafe931f03bc0afbed181a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 20:49:29 -0400 Subject: [PATCH 02/32] control: keep channel responsive when window is covered (disable App Nap); fail fast on occluded screenshots Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++++++ apps/effectcraft/Cargo.toml | 2 ++ apps/effectcraft/src/main.rs | 15 +++++++++++++++ crates/ui-egui/src/lib.rs | 6 ++++++ 4 files changed, 31 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b29b91818..ae0a711f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "effectcraft-ui-egui", "eframe", "egui", + "objc2-foundation 0.3.2", "rfd", "serde_json", "winit", @@ -5465,6 +5466,13 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 1759d4c02..3240c78d1 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -21,3 +21,5 @@ rfd = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } +# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling). +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 6ee51c56d..a2b3fedd5 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -70,6 +70,7 @@ fn main() -> eframe::Result { app.hooks.pick_open_project = Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); if let Some(port) = control_port { + disable_app_nap(); let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); } @@ -91,3 +92,17 @@ fn agent_event_loop(agent: bool) -> Option { let _ = agent; None } + +/// Agents drive the app while its window is covered or on another Space. macOS App Nap would +/// throttle the event loop then, stalling the control channel; opt out for `--control` runs. +fn disable_app_nap() { + #[cfg(target_os = "macos")] + { + use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; + let info = NSProcessInfo::processInfo(); + let reason = NSString::from_str("EffectCraft control channel"); + let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical; + // Leaked on purpose: the activity lasts for the life of the process. + std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d27..6b7f089fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -313,6 +313,12 @@ impl EffectcraftApp { } control::Outcome::AfterInput => self.input_waiters.push(reply), control::Outcome::Screenshot { path, crop } => { + // A covered or minimized window presents no frames (macOS skips its redraws), so + // a screenshot would never arrive and nothing would tick its timeout. + if ctx.input(|i| i.viewport().occluded == Some(true) || i.viewport().minimized == Some(true)) { + let _ = reply.send(json!({"ok": false, "error": "window is covered or minimized: call ui.focus first (render.frame renders comp pixels without the window)"})); + continue; + } let token = self.next_token; self.next_token += 1; let settle = now + 0.35; From 4f47a01fed38f8c814536a229f5f9136eeb70e27 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:19:23 -0400 Subject: [PATCH 03/32] M0.9: AE-style Properties panel, context tab titles, AE default workspace; headless UI snapshot tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Properties panel (panels/properties.rs): Layer Transform with keyframe navigator or stopwatch, AE value formatting (integers, %, Nx+N° rotation), linked Scale, Reset; Text (font/style/size/leading/tracking/stroke, fill/stroke toggles); Paragraph (7 alignments); Text Animation > Add Animator; scrolling. - Tabs name what they show: Composition , timeline, Effect Controls , Properties: . - Default workspace: full-height right column, Preview over Properties. - engine: time.go nextKey/prevKey accept prop uid; layer.setText supports all seven justify modes, applyFill/applyStroke and Auto leading (fixes the Paragraph 'justify' button mapping to Justify All). - examples/snapshot: render the real UI headless (egui_kittest + wgpu) with scripted control-channel steps. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- Cargo.lock | 1 + crates/engine/src/commands/layer.rs | 21 +- crates/engine/src/commands/time.rs | 14 +- crates/engine/src/tests.rs | 55 ++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/examples/snapshot.rs | 130 +++++ crates/ui-egui/src/dock.rs | 34 +- crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/panels/effect_controls.rs | 41 +- crates/ui-egui/src/panels/mod.rs | 3 +- crates/ui-egui/src/panels/properties.rs | 508 +++++++++++++++++++ 12 files changed, 765 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/examples/snapshot.rs create mode 100644 crates/ui-egui/src/panels/properties.rs diff --git a/CLAUDE.md b/CLAUDE.md index 54c15ac0f..979931a42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,5 +23,6 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e ## Running and looking at the app - `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`). -- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`; or headless: `cargo run -p effectcraft-cli -- snapshot out.png`. +- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen). +- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l `); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen. - Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; keep every `Cargo.toml` valid (the `crates/*` glob). diff --git a/Cargo.lock b/Cargo.lock index ae0a711f6..e0e234725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "effectcraft-color", "effectcraft-engine", "effectcraft-geom", + "effectcraft-host", "effectcraft-keyframe", "effectcraft-path", "effectcraft-project", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index f329dfaa3..6adad1015 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -95,8 +95,17 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { if let Some(v) = f_p(p, "tracking") { d.tracking = v; } - if let Some(v) = f_p(p, "leading") { - d.leading = Some(v); + match p.get("leading") { + Some(Value::Number(n)) => d.leading = n.as_f64(), + // "auto" or null: Auto Leading (120% of the font size). + Some(Value::String(_) | Value::Null) => d.leading = None, + _ => {} + } + if let Some(v) = b_p(p, "applyFill") { + d.apply_fill = v; + } + if let Some(v) = b_p(p, "applyStroke") { + d.apply_stroke = v; } if let Some(v) = b_p(p, "allCaps") { d.all_caps = v; @@ -108,10 +117,14 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { d.faux_italic = v; } if let Some(j) = str_p(p, "justify") { + // AE's seven Paragraph alignment buttons. d.justify = match j.to_ascii_lowercase().as_str() { "center" | "centre" => Justify::Center, "right" => Justify::Right, - "justify" | "justifyall" => Justify::JustifyAll, + "justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft, + "justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter, + "justifyright" | "justifylastright" => Justify::JustifyLastRight, + "justifyall" => Justify::JustifyAll, _ => Justify::Left, }; } @@ -927,7 +940,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, text?, size?, font?, style?, fill?, stroke?, strokeWidth?, tracking?, leading?, justify?, allCaps?, fauxBold?, fauxItalic?}", + "{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, fauxBold?, fauxItalic?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 0b4e20ff2..df2b7af92 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -56,13 +56,23 @@ fn go(s: &mut Session, p: &Value) -> Result { } else { comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)).collect() }; + // `prop` (uid) narrows the search to one property: the Properties panel's ◀ ▶ arrows. + let only = p.get("prop").and_then(Value::as_u64); + if only.is_some() { + times.clear(); + } for l in layers { l.props.walk("", &mut |_, pr| { + if only.is_some_and(|u| u != pr.uid) { + return; + } for k in &pr.keys { times.push(l.comp_time(k.time)); } }); - times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); + if only.is_none() { + times.extend(l.markers.iter().map(|m| l.comp_time(m.time))); + } } times.sort(); times.dedup(); @@ -93,6 +103,6 @@ pub fn specs() -> Vec { cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))), cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))), - cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey}", has_comp, go), + cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go), ] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52f..bea8c2d07 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -114,3 +114,58 @@ fn time_navigation() { s.execute("time.set", json!({"frame": 45})).unwrap(); assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50); } + +#[test] +fn time_navigation_per_property() { + let mut s = demo(); + // An animated property and the comp times of its keys. + let comp = s.active_comp().unwrap().clone(); + let mut found = None; + for l in &comp.layers { + l.props.walk("", &mut |_, pr| { + if found.is_none() && pr.keys.len() >= 2 { + found = Some((pr.uid, pr.keys.iter().map(|k| l.comp_time(k.time)).collect::>())); + } + }); + } + let (uid, times) = found.expect("demo has an animated property"); + s.set_time(effectcraft_time::Tick::ZERO); + let half = comp.frame_duration().0 / 2; + let expect = times.iter().copied().filter(|t| t.0 > half).min().unwrap(); + s.execute("time.go", json!({"to": "nextKey", "prop": uid})).unwrap(); + let snap = |t| comp.frame_rate.snap(t); + assert_eq!(s.time(), snap(expect)); + // Going back from past the last key lands on the last key. + s.set_time(comp.duration); + s.execute("time.go", json!({"to": "prevKey", "prop": uid})).unwrap(); + assert_eq!(s.time(), snap(*times.iter().max().unwrap())); +} + +#[test] +fn set_text_paragraph_fill_and_leading() { + use effectcraft_keyframe::{Justify, Value as KValue}; + let mut s = demo(); + let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + let doc = |s: &Session| { + let l = s.active_comp().unwrap().layer(crate::project::LayerId(t)).unwrap().clone(); + match &l.props.prop("text/sourceText").unwrap().value { + KValue::Text(d) => (**d).clone(), + v => panic!("{v:?}"), + } + }; + for (key, j) in [ + ("justifyLeft", Justify::JustifyLastLeft), + ("justifyCenter", Justify::JustifyLastCenter), + ("justifyRight", Justify::JustifyLastRight), + ("justifyAll", Justify::JustifyAll), + ("center", Justify::Center), + ] { + s.execute("layer.setText", json!({"layer": t, "justify": key})).unwrap(); + assert_eq!(doc(&s).justify, j, "{key}"); + } + s.execute("layer.setText", json!({"layer": t, "leading": 50, "applyFill": false, "applyStroke": true})).unwrap(); + let d = doc(&s); + assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true)); + s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap(); + assert_eq!(doc(&s).leading, None); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 512b9b14c..d27ef79c8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -31,3 +31,4 @@ kurbo = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { workspace = true } pollster = "0.4" +effectcraft-host = { workspace = true } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs new file mode 100644 index 000000000..6cc9b175e --- /dev/null +++ b/crates/ui-egui/examples/snapshot.rs @@ -0,0 +1,130 @@ +//! Headless UI snapshot: runs the real [`EffectcraftApp`] without a window (egui_kittest + wgpu) +//! and writes PNGs. Works regardless of window focus, Spaces or display sleep, so agents can +//! look at the UI at any time. +//! +//! ```text +//! cargo run -p effectcraft-ui-egui --example snapshot -- [--out ui.png] [--size 1680x1020] +//! [--scale 2] [--settle 1.5] [--empty] [--script steps.jsonl | --step '']... +//! ``` +//! +//! Each script step is a control-channel request (`{"method":"engine.execute","params":{...}}`, +//! see `docs/control-protocol.md`), run in order with the app settling in between. The extra +//! method `{"method":"snap","params":{"path":"x.png"}}` writes an intermediate snapshot. +//! Replies are printed to stdout as JSON lines. + +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use effectcraft_ui_egui::{ControlRequest, EffectcraftApp}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +struct Args { + out: String, + size: (f32, f32), + scale: f32, + settle: f64, + demo: bool, + steps: Vec, +} + +fn parse_args() -> Result { + let mut a = Args { out: "ui.png".into(), size: (1680.0, 1020.0), scale: 2.0, settle: 1.5, demo: true, steps: Vec::new() }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + let mut val = || it.next().ok_or_else(|| format!("{arg} needs a value")); + match arg.as_str() { + "--out" => a.out = val()?, + "--size" => { + let v = val()?; + let (w, h) = v.split_once('x').ok_or("--size WxH")?; + a.size = (w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?); + } + "--scale" => a.scale = val()?.parse().map_err(|_| "bad --scale")?, + "--settle" => a.settle = val()?.parse().map_err(|_| "bad --settle")?, + "--empty" => a.demo = false, + "--step" => a.steps.push(serde_json::from_str(&val()?).map_err(|e| format!("--step: {e}"))?), + "--script" => { + let path = val()?; + let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) { + a.steps.push(serde_json::from_str(line).map_err(|e| format!("{path}: {e}: {line}"))?); + } + } + "-h" | "--help" => { + return Err("usage: snapshot [--out ui.png] [--size WxH] [--scale 2] [--settle secs] [--empty] [--script f.jsonl | --step json]...".into()); + } + _ => return Err(format!("unknown argument {arg}")), + } + } + Ok(a) +} + +/// Step the app for `secs` of wall time so background frame renders land in the viewer. +fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) { + let end = Instant::now() + Duration::from_secs_f64(secs); + while Instant::now() < end { + h.step(); + std::thread::sleep(Duration::from_millis(30)); + } + h.step(); +} + +fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> { + let img = h.render()?; + img.save(path).map_err(|e| format!("{path}: {e}"))?; + println!("{}", json!({"snapshot": path, "width": img.width(), "height": img.height()})); + Ok(()) +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + }; + let mut session = effectcraft_host::session(); + if args.demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let (tx, rx) = mpsc::channel::(); + let mut harness = Harness::builder() + .with_size(egui::vec2(args.size.0, args.size.1)) + .with_pixels_per_point(args.scale) + .wgpu() + .build_eframe(move |_cc| EffectcraftApp::new(session).with_control(rx)); + settle(&mut harness, args.settle); + for step in &args.steps { + let method = step["method"].as_str().unwrap_or_default().to_string(); + let params = step.get("params").cloned().unwrap_or(json!({})); + if method == "snap" { + if let Err(e) = snap(&mut harness, params["path"].as_str().unwrap_or("snap.png")) { + eprintln!("{e}"); + } + continue; + } + let (reply_tx, reply_rx) = mpsc::channel(); + let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx }); + // Requests are answered on a later frame (some wait for input to be processed). + let deadline = Instant::now() + Duration::from_secs(10); + let reply = loop { + harness.step(); + if let Ok(v) = reply_rx.try_recv() { + break v; + } + if Instant::now() > deadline { + break json!({"ok": false, "error": "no reply within 10 s"}); + } + std::thread::sleep(Duration::from_millis(10)); + }; + println!("{}", json!({"method": method, "reply": reply})); + settle(&mut harness, 0.3); + } + settle(&mut harness, args.settle.min(1.0)); + if let Err(e) = snap(&mut harness, &args.out) { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7a523c89c..8a8840cbc 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -240,19 +240,12 @@ pub fn workspace(name: &str) -> DockNode { ), tabs(&[Timeline, RenderQueue], 0), ), - // Default: Project/Effect Controls | Composition | Info, Audio, Preview, Effects & Presets… - _ => vsplit( - Ratio(0.56), - hsplit( - FixedA(300.0), - tabs(&[Project, EffectControls], 0), - hsplit( - FixedB(290.0), - tabs(&[Composition, Layer], 0), - vsplit(Ratio(0.42), tabs(&[Info, Audio, Preview], 2), tabs(&[EffectsPresets, Properties, Character, Paragraph, Align], 0)), - ), - ), - tabs(&[Timeline, RenderQueue], 0), + // Default (as in After Effects): Project/Effect Controls | Composition over the Timeline, + // and a full-height right column with Preview above the Properties panel. + _ => hsplit( + FixedB(300.0), + vsplit(Ratio(0.56), hsplit(FixedA(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0)), + vsplit(FixedA(250.0), tabs(&[Preview, Info, Audio], 0), tabs(&[Properties, EffectsPresets, Character, Paragraph, Align], 0)), ), } } @@ -411,7 +404,15 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa } /// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus). -pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry) -> Vec { +/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style). +pub fn draw_group_chrome( + ui: &mut egui::Ui, + g: &Group, + focused: PanelKind, + t: &Tokens, + reg: &mut crate::automation::Registry, + title: &dyn Fn(PanelKind) -> String, +) -> Vec { let mut actions = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); @@ -429,7 +430,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T let text_y = strip.min.y + 16.0; for (i, p) in g.panels.iter().enumerate() { let is_active = i == g.active; - let galley = painter.layout_no_wrap(p.title().to_string(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); + let label = title(*p); + let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; let w = galley.size().x + 16.0 + menu_w; if x + w > strip.max.x - 20.0 && i > g.active { @@ -445,7 +447,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T } let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()); - reg.add(&format!("panel.tab.{}", p.id()), tab, p.title()); + reg.add(&format!("panel.tab.{}", p.id()), tab, &label); let label_x = tab.min.x + 8.0; let label_w = galley.size().x; let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b7f089fa..821a946d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -467,14 +467,31 @@ impl EffectcraftApp { } } + /// Tab labels that name what the panel shows, as After Effects does: "Composition Intro", + /// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp. + fn tab_titles(&self) -> Vec<(PanelKind, String)> { + let mut out = Vec::new(); + let Some(comp) = self.session.active_comp() else { return out }; + let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); + out.push((PanelKind::Composition, format!("Composition {cname}"))); + out.push((PanelKind::Timeline, cname)); + if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { + out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); + out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + } + out + } + fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { let t = self.tokens; let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }); let mut groups = Vec::new(); dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); let mut actions = Vec::new(); + let titles = self.tab_titles(); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto)); + actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); } self.ui.dock = dock; for g in &groups { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bb418ccd9..0397c552d 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1,5 +1,4 @@ -//! Effect Controls (the selected layer's effects with all parameters) and the Properties panel -//! (essential properties: transform + text/shape basics). +//! Effect Controls: the selected layer's effects with all parameters. use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property}; @@ -376,41 +375,3 @@ impl EffectKind for PropGroup { } } } - -/// Properties panel: the selected layer's transform (and text basics), always visible. -pub fn properties(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); - let ctx = ui.ctx().clone(); - let Some(layer) = selected_layer(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer", Tokens::ui(12.0), t.text_faint); - return; - }; - let Some(cid) = app.session.active_comp_id() else { return }; - let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default())); - let snap_project = app.session.project.clone(); - let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() }; - let mut y = rect.min.y + 8.0; - p.text(pos2(rect.min.x + 10.0, y + 8.0), Align2::LEFT_CENTER, &layer.name, Tokens::semibold(13.0), t.text); - p.text(pos2(rect.min.x + 10.0, y + 24.0), Align2::LEFT_CENTER, layer.source.type_name(), Tokens::ui(11.0), t.text_dim); - y += 40.0; - let mut actions: Actions = vec![]; - if let Some(tr) = layer.transform().cloned() { - p.text(pos2(rect.min.x + 10.0, y + 10.0), Align2::LEFT_CENTER, "Layer Transform", Tokens::semibold(12.0), t.text); - y += 24.0; - for pr in tr.props() { - if pr.three_d_only && !layer.is_3d() { - continue; - } - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 24.0)); - prop_row(app, ui, &p, &layer, pr, &ectx, r, 16.0, &mut actions); - y += 24.0; - } - } - for (id, params) in actions { - if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { - app.ui.status = e; - } - } -} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848b..2d5924dd0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod effects_presets; pub mod info; pub mod misc; pub mod project; +pub mod properties; pub mod text_panels; pub mod timeline; pub mod viewer; @@ -58,7 +59,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Character => text_panels::character(app, ui, rect), PanelKind::Paragraph => text_panels::paragraph(app, ui, rect), PanelKind::Align => text_panels::align(app, ui, rect), - PanelKind::Properties => effect_controls::properties(app, ui, rect), + PanelKind::Properties => properties::show(app, ui, rect), PanelKind::Audio => misc::audio(app, ui, rect), PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs new file mode 100644 index 000000000..7e41c82d7 --- /dev/null +++ b/crates/ui-egui/src/panels/properties.rs @@ -0,0 +1,508 @@ +//! Properties panel: the selected layer's essential properties in one context-sensitive stack, +//! like After Effects' Properties panel. Sections appear by layer type: +//! +//! - **Layer Transform** (all layers but cameras/lights): keyframe navigator (◀ ◆ ▶) or +//! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation, Reset. +//! - **Text** (text layers): font family/style, size, leading, tracking, stroke width, fill and +//! stroke with enable checkboxes; "More" opens the Character panel. +//! - **Paragraph** (text layers): the seven alignment buttons; "More" opens the Paragraph panel. +//! - **Text Animation** (text layers): Add Animator menu. + +use effectcraft_engine::keyframe::{Justify, TextDoc, Value}; +use effectcraft_engine::project::{Layer, LayerSource, Property}; +use effectcraft_engine::render::EvalCtx; +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use crate::dock::PanelKind; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +type Actions = Vec<(String, serde_json::Value)>; + +const ROW_H: f32 = 22.0; +const PAD: f32 = 12.0; + +/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property). +const ANIMATORS: &[(&str, &str)] = &[ + ("Anchor Point", "anchor"), + ("Position", "position"), + ("Scale", "scale"), + ("Skew", "skew"), + ("Rotation", "rotation"), + ("Opacity", "opacity"), + ("Fill Color", "fillColor"), + ("Stroke Color", "strokeColor"), + ("Stroke Width", "strokeWidth"), + ("Tracking", "tracking"), + ("Line Spacing", "lineSpacing"), + ("Character Offset", "characterOffset"), + ("Blur", "blur"), +]; + +/// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key). +const ALIGNS: [(Justify, &str); 7] = [ + (Justify::Left, "left"), + (Justify::Center, "center"), + (Justify::Right, "right"), + (Justify::JustifyLastLeft, "justifyLeft"), + (Justify::JustifyLastCenter, "justifyCenter"), + (Justify::JustifyLastRight, "justifyRight"), + (Justify::JustifyAll, "justifyAll"), +]; + +/// Value text the way AE prints it: whole numbers without decimals, otherwise one decimal. +fn decimals(v: f64) -> usize { + if (v - v.round()).abs() < 1e-6 { 0 } else { 1 } +} + +/// Rotation as AE shows it: revolutions and remaining degrees (`1x+30°`, `-0x-45°`). +pub fn split_rotation(deg: f64) -> (i64, f64) { + let rev = (deg / 360.0).trunc(); + (rev as i64, deg - rev * 360.0) +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let ctx = ui.ctx().clone(); + let comp = app.session.active_comp().cloned(); + let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|id| c.layer(*id)).cloned()); + let (Some(comp), Some(cid), Some(layer)) = (comp, app.session.active_comp_id(), layer) else { + p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer to see its properties", Tokens::ui(12.0), t.text_faint); + return; + }; + let project = app.session.project.clone(); + let expr = app.session.expr.clone(); + let now = app.session.time(); + let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() }; + let x0 = rect.min.x + PAD; + let w = rect.width() - 2.0 * PAD; + // Vertical scrolling: content height is known after drawing, so clamp with last frame's. + let scroll_id = egui::Id::new("props-scroll"); + let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0)); + if ui.rect_contains_pointer(rect) { + scroll -= ui.input(|i| i.smooth_scroll_delta.y); + } + scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0)); + let top = rect.min.y + 8.0 - scroll; + let mut y = top; + let mut actions: Actions = vec![]; + + // ---------------------------------------------------------------- Layer Transform + if let Some(tr) = layer.transform().cloned() { + section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); + let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H)); + let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click()); + p.text(rr.right_center(), Align2::RIGHT_CENTER, "Reset", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent }); + app.auto.add("properties.transform.reset", rr, "Reset Transform"); + if resp.clicked() { + for pr in tr.props() { + actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid}))); + } + } + y += ROW_H + 2.0; + for pr in tr.props() { + if pr.three_d_only && !layer.is_3d() { + continue; + } + transform_row(app, ui, &p, &layer, pr, &ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &mut actions); + y += ROW_H; + } + y += 10.0; + } + + // ---------------------------------------------------------------- Text / Paragraph / Animation + if matches!(layer.source, LayerSource::Text) + && let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer) + { + divider(&p, x0, w, y - 4.0, &t); + y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions); + divider(&p, x0, w, y - 4.0, &t); + y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions); + divider(&p, x0, w, y - 4.0, &t); + section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation"); + y += ROW_H + 10.0; + let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0)); + let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click()); + p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); + icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text); + p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, "Add Animator", Tokens::ui(12.0), t.text); + app.auto.add("properties.addAnimator", br, "Add Animator"); + let pop = egui::Id::new("props-add-animator-pop"); + if resp.clicked() { + widgets::open_popup(ui, pop); + } + let labels: Vec = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect(); + if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) { + actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": ANIMATORS[i].1}))); + } + y += 24.0; + } + + y += 40.0; + let content_h = y - top; + ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h))); + if content_h > rect.height() { + // Thin scroll indicator. + let frac = rect.height() / content_h; + let bar_h = (rect.height() * frac).max(24.0); + let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0)); + p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6)); + } + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } +} + +fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) { + let t = app.tokens; + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text); + let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)); + let _ = ui; + app.auto.add(auto, r, title); +} + +fn divider(p: &egui::Painter, x0: f32, w: f32, y: f32, t: &Tokens) { + p.line_segment([pos2(x0 - 4.0, y), pos2(x0 + w + 4.0, y)], Stroke::new(1.0, t.separator)); +} + +fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { + let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); +} + +/// One transform row: keyframe navigator or stopwatch, name, value fields. +#[allow(clippy::too_many_arguments)] +fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { + let t = app.tokens; + let cy = r.center().y; + let uid = pr.uid; + let lid = layer.id.0; + let base = format!("properties.prop.{uid}"); + if pr.is_animated() { + // ◀ ◆ ▶: previous key, add/remove key at the CTI, next key. + let now = ectx.time; + let half = ectx.comp.frame_duration().0 / 2; + let on_key = pr.keys.iter().any(|k| (layer.comp_time(k.time).0 - now.0).abs() <= half); + let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)]; + for (what, cx) in cells { + let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0)); + let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click()); + let col = if resp.hovered() { t.text } else { t.text_dim }; + match what { + "prev" => triangle(p, cr.center(), true, col), + "next" => triangle(p, cr.center(), false, col), + _ => { + let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0)); + if on_key { + icons::paint(p, kr, Icon::Keyframe, t.keyframe); + } else { + let c = kr.center(); + let d = 4.5; + p.add(egui::Shape::closed_line( + vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)], + Stroke::new(1.0, col), + )); + } + } + } + app.auto.add(&format!("{base}.{what}Key"), cr, &pr.name); + if resp.clicked() { + match what { + "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), + "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), + } + } + } + } else { + let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0)); + let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click()); + icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); + if resp.clicked() { + actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); + } + } + p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); + let vx = r.min.x + (r.width() * 0.5).max(130.0); + let merge = format!("props-{uid}"); + let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); + let value = ectx.value(layer, pr); + let is_scale = pr.name == "Scale"; + let pct = is_scale || pr.name == "Opacity"; + let suffix = if pct { "%" } else { "" }; + match &value { + Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { + let (rev, deg) = split_rotation(*v); + let g = p.layout_no_wrap(format!("{rev}x{}", if deg < 0.0 { "" } else { "+" }), Tokens::ui(12.0), t.hot_text); + let gw = g.size().x; + p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text); + let (vr, nv, _) = + widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("props-v", uid)), deg, 0.5, (-1e9, 1e9), decimals(deg), "°", &t); + app.auto.add(&format!("{base}.value"), vr, &pr.name); + if let Some(nv) = nv { + set(actions, json!(rev as f64 * 360.0 + nv)); + } + } + Value::Scalar(v) => { + let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) }; + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t); + app.auto.add(&format!("{base}.value"), vr, &pr.name); + if let Some(nv) = nv { + set(actions, json!(nv)); + } + } + Value::Vec2(_) | Value::Vec3(_) => { + let c = value.components(); + let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() }; + let mut x = vx; + let link_id = egui::Id::new(("props-scale-link", lid)); + let linked = is_scale && ui.data(|d| d.get_temp::(link_id)).unwrap_or(true); + if is_scale { + // Constrain Proportions toggle, left of the values. + let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0)); + let resp = ui.interact(lr, link_id.with("btn"), Sense::click()); + icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); + app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions"); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + } + for d in 0..n.min(c.len()) { + let (vr, nv, _) = + widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t); + app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); + if let Some(nv) = nv { + let mut nc = c.clone(); + if linked && c[d].abs() > 1e-9 { + let k = nv / c[d]; + for v in nc.iter_mut().take(n) { + *v *= k; + } + } + nc[d] = nv; + set(actions, json!(nc)); + } + x = vr.max.x + 10.0; + } + } + _ => {} + } +} + +/// Text section; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn text_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + doc: &TextDoc, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + let lid = layer.id.0; + let set = |actions: &mut Actions, v: serde_json::Value| { + let mut v = v; + v["layer"] = json!(lid); + actions.push(("layer.setText".into(), v)); + }; + section_header(app, ui, p, x0, w, y, "Text", "properties.text"); + y += ROW_H + 4.0; + // Font family and style. + let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let pop = egui::Id::new("props-font-pop"); + if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add("properties.text.font", fr, "Font family"); + let fams: Vec = effectcraft_engine::text_families(); + if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) { + set(actions, json!({"font": fams[i]})); + } + y += 28.0; + let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); + let spop = egui::Id::new("props-style-pop"); + if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add("properties.text.style", sr, "Font style"); + let styles: Vec = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect(); + if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) { + set(actions, json!({"style": styles[i]})); + } + y += 32.0; + // Size / leading, tracking / stroke width. + let col2 = x0 + w / 2.0; + let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| { + p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t); + app.auto.add(&format!("properties.text.{key}"), r, key); + nv + }; + if let Some(v) = field(app, ui, x0, y, "T", "size", doc.size, (1.0, 2000.0), " px") { + set(actions, json!({"size": v, "merge": "props-text-size"})); + } + match doc.leading { + Some(l) => { + if let Some(v) = field(app, ui, col2, y, "A", "leading", l, (0.0, 5000.0), " px") { + set(actions, json!({"leading": v, "merge": "props-text-leading"})); + } + } + None => { + // Auto Leading: click to switch to an explicit value (120% of the size). + p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim); + let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0)); + let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click()); + p.text(pos2(r.min.x + 2.0, r.center().y), Align2::LEFT_CENTER, "Auto", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.hot_text }); + app.auto.add("properties.text.leading", r, "leading"); + if resp.clicked() { + set(actions, json!({"leading": (doc.size * 1.2).round()})); + } + } + } + y += 26.0; + if let Some(v) = field(app, ui, x0, y, "VA", "tracking", doc.tracking, (-1000.0, 10000.0), "") { + set(actions, json!({"tracking": v.round(), "merge": "props-text-tracking"})); + } + if let Some(v) = field(app, ui, col2, y, "W", "strokeWidth", doc.stroke_width, (0.0, 500.0), " px") { + set(actions, json!({"strokeWidth": v, "merge": "props-text-strokeWidth"})); + } + y += 28.0; + // Fill and Stroke, each with an enable checkbox, swatch and label. + for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { + let ry = y + i as f32 * 24.0; + let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0)); + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() { + let k = if key == "fill" { "applyFill" } else { "applyStroke" }; + set(actions, json!({k: !on})); + } + app.auto.add(&format!("properties.text.{key}.enabled"), cr, label); + let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0)); + let pop = egui::Id::new(("props-color-pop", key)); + if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("properties.text.{key}"), sw, label); + let mut rgb = [c[0], c[1], c[2]]; + if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); + } + p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); + } + y += 52.0; + y = more_button(app, ui, p, x0, y, "props-text-more", "properties.text.more", PanelKind::Character); + y + 10.0 +} + +/// Paragraph section; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn paragraph_section( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + doc: &TextDoc, + x0: f32, + w: f32, + mut y: f32, + actions: &mut Actions, +) -> f32 { + let t = app.tokens; + section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph"); + y += ROW_H + 4.0; + for (i, (j, key)) in ALIGNS.into_iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0)); + let on = doc.justify == j; + let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click()); + p.rect_filled( + r, + 3.0, + if on { + t.pressed + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }, + ); + paint_align_glyph(p, r, j, if on { t.text } else { t.text_dim }); + app.auto.add(&format!("properties.paragraph.{key}"), r, key); + if resp.clicked() { + actions.push(("layer.setText".into(), json!({"layer": layer.id.0, "justify": key}))); + } + } + y += 30.0; + y = more_button(app, ui, p, x0, y, "props-para-more", "properties.paragraph.more", PanelKind::Paragraph); + y + 10.0 +} + +/// Four lines showing an alignment: last line short for the "justify last …" modes. +fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) { + let full = r.width() - 8.0; + for k in 0..4 { + let ly = r.min.y + 6.0 + k as f32 * 3.5; + let last = k == 3; + let lw = match j { + Justify::Left | Justify::Center | Justify::Right => { + if k % 2 == 1 { + full * 0.6 + } else { + full + } + } + Justify::JustifyAll => full, + _ if last => full * 0.55, + _ => full, + }; + let lx = match j { + Justify::Center | Justify::JustifyLastCenter => r.center().x - lw / 2.0, + Justify::Right | Justify::JustifyLastRight => r.max.x - 4.0 - lw, + _ => r.min.x + 4.0, + }; + p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], Stroke::new(1.2, c)); + } +} + +/// "··· More" button that opens the full panel; returns the y below it. +#[allow(clippy::too_many_arguments)] +fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 { + let t = app.tokens; + let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0)); + let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg }); + p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text); + p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "More", Tokens::ui(12.0), t.text); + app.auto.add(auto, r, "More"); + if resp.clicked() { + app.show_panel(panel); + } + y + 28.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotation_splits_like_ae() { + assert_eq!(split_rotation(0.0), (0, 0.0)); + assert_eq!(split_rotation(390.0), (1, 30.0)); + assert_eq!(split_rotation(-45.0), (0, -45.0)); + assert_eq!(split_rotation(-725.0), (-2, -5.0)); + } + + #[test] + fn decimals_hide_whole_numbers() { + assert_eq!(decimals(960.0), 0); + assert_eq!(decimals(960.5), 1); + } +} From a2ff724ac2e7311585c6749400ed61d674f342b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:34:33 -0400 Subject: [PATCH 04/32] M12.1: render cache + parallel compositing fast paths - render: LayerCache (content-keyed processed-layer cache: source -> masks -> effects, keyed by a hash of the layer's non-transform props evaluated at the frame time, solid item, switches, scale; time folded in for clock-driven effects / wiggle paths). Static and transform-only layers render once; edits only re-render the edited layer. Session + UI frame jobs share it. - effects: TIME_DEPENDENT list + test that every clock-reading effect is listed. - raster: affine composite fast path (per-row span clipping, unchecked interior bilinear, integer-translation copies, inlined Normal blend), checked against composite_warp_reference; box/Gaussian blur without transposes, two ping-pong buffers; accumulate_warp so motion blur sums into one buffer instead of one full-frame image per sub-sample. - ui: viewer frame is urgent and jumps the prefetch queue; stale-revision jobs are dropped; paused prefetch waits for the viewer frame; "Frame Render Time" reports viewer frames only; parallel f32 -> Color32 conversion. - cli: `frame --bench N` (per-layer/per-effect timings, wall + CPU time) and `frame --bench-play N` (consecutive frames with/without the layer cache). - tests: cache transparency (scrub, edits of values/keys/effects/masks/ solids/text/timing), hit/miss accounting, time-dependent effects, demo cached == uncached, fast warp/blur/accumulate == reference. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 33 ++++ apps/effectcraft-cli/Cargo.toml | 3 + apps/effectcraft-cli/src/main.rs | 117 ++++++++++++- crates/effects/src/lib.rs | 50 ++++++ crates/engine/src/lib.rs | 6 +- crates/engine/src/tests.rs | 29 ++++ crates/raster/src/blur.rs | 183 +++++++++++++++----- crates/raster/src/lib.rs | 2 +- crates/raster/src/warp.rs | 250 +++++++++++++++++++++++++++- crates/render/src/cache.rs | 245 +++++++++++++++++++++++++++ crates/render/src/lib.rs | 104 +++++++++--- crates/render/src/tests.rs | 171 +++++++++++++++++++ crates/ui-egui/src/frames.rs | 102 ++++++++++-- crates/ui-egui/src/lib.rs | 34 +++- crates/ui-egui/src/panels/viewer.rs | 6 +- 15 files changed, 1247 insertions(+), 88 deletions(-) create mode 100644 crates/render/src/cache.rs diff --git a/Cargo.lock b/Cargo.lock index b29b91818..6673f6aff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpu-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1057,6 +1067,7 @@ dependencies = [ name = "effectcraft-cli" version = "0.1.0" dependencies = [ + "cpu-time", "effectcraft-engine", "effectcraft-render", "effectcraft-time", @@ -5040,6 +5051,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5049,6 +5076,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d06..a62a4b812 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -18,3 +18,6 @@ effectcraft-render = { workspace = true } effectcraft-time = { workspace = true } serde_json = { workspace = true } image = { workspace = true } + + +cpu-time = "1" diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58f..eb930fcb9 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1,13 +1,18 @@ //! EffectCraft headless CLI. //! //! ```text -//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png +//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] +//! [--bench N] [--bench-play N] --out out.png //! effectcraft-cli run [project.ecproj | --demo] '' '' … (prints results) //! effectcraft-cli commands (lists commands) //! ``` +//! +//! `--bench N` renders the frame N times without the layer cache and prints per-layer and +//! per-effect timings; `--bench-play N` renders N consecutive frames with and without the cache. use effectcraft_engine::Session; -use effectcraft_render::RenderOpts; +use effectcraft_engine::project::ItemId; +use effectcraft_render::{LayerCache, LayerTiming, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -22,7 +27,8 @@ fn open(s: &mut Session, args: &[String]) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // `--demo` or no project: the demo project. let _ = s.execute("file.openDemoProject", json!({})); } } @@ -31,6 +37,105 @@ fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> { args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str) } +fn median(v: &mut [f64]) -> f64 { + v.sort_by(f64::total_cmp); + if v.is_empty() { 0.0 } else { v[v.len() / 2] } +} + +fn min(v: &[f64]) -> f64 { + v.iter().copied().fold(f64::INFINITY, f64::min) +} + +/// CPU time of the whole process (all threads) in ms: a load-independent measure of work. +fn cpu_ms() -> f64 { + cpu_time::ProcessTime::try_now().map(|t| t.as_duration().as_secs_f64() * 1e3).unwrap_or(0.0) +} + +/// One frame: (wall ms, CPU ms, per-layer timings). +fn render_timed(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, cache: Option<&LayerCache>) -> (f64, f64, Vec) { + let prof = std::sync::Mutex::new(Vec::::new()); + let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.cache = cache; + r.profile = Some(&prof); + let c0 = cpu_ms(); + let t0 = std::time::Instant::now(); + let img = r.comp_frame(cid, t); + let ms = t0.elapsed().as_secs_f64() * 1e3; + let cpu = cpu_ms() - c0; + std::hint::black_box(&img); + (ms, cpu, prof.into_inner().unwrap_or_default()) +} + +type LayerRow = (usize, String, Vec, Vec, Vec<(String, Vec)>); + +/// `frame --bench N`: render the same frame N times without the layer cache (the full cost of a +/// frame) and print min/median frame time plus min per-layer and per-effect times. +fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) { + let mut totals = Vec::with_capacity(n); + let mut cpus = Vec::with_capacity(n); + let mut per_layer: Vec = Vec::new(); + for _ in 0..n { + let (ms, cpu, prof) = render_timed(s, cid, t, opts, None); + totals.push(ms); + cpus.push(cpu); + for (i, lt) in prof.into_iter().enumerate() { + if per_layer.len() <= i { + per_layer.push((lt.depth, lt.layer.clone(), vec![], vec![], vec![])); + } + let e = &mut per_layer[i]; + e.2.push(lt.process_ms); + e.3.push(lt.composite_ms); + for (j, (id, ms)) in lt.effects.into_iter().enumerate() { + if e.4.len() <= j { + e.4.push((id, vec![])); + } + e.4[j].1.push(ms); + } + } + } + eprintln!( + "bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms", + opts.scale, + min(&totals), + median(&mut totals), + min(&cpus) + ); + for (d, name, p, c, fx) in per_layer { + eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2); + for (id, v) in fx { + eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2); + } + } +} + +/// `frame --bench-play N`: render N consecutive frames from `t` as playback does, without and +/// with the layer cache, and print per-frame times. +fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) { + let Some(comp) = s.project.comp(cid) else { return }; + let f0 = comp.frame_rate.frame_at(t); + let cache = LayerCache::default(); + for (label, c) in [("no cache", None), ("layer cache", Some(&cache))] { + let runs: Vec<(f64, f64)> = (0..n as i64) + .map(|i| { + let (ms, cpu, _) = render_timed(s, cid, comp.frame_rate.tick_of(f0 + i), opts, c); + (ms, cpu) + }) + .collect(); + let mut v: Vec = runs.iter().map(|r| r.0).collect(); + let mean = v.iter().sum::() / n as f64; + let cpu = runs.iter().map(|r| r.1).sum::() / n as f64; + eprintln!( + "bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms", + opts.scale, + median(&mut v), + min(&v) + ); + } + let st = cache.stats(); + eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6); +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let Some(cmd) = args.first().cloned() else { usage() }; @@ -54,6 +159,12 @@ fn main() { }; let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0); let out = opt(rest, "--out").unwrap_or("frame.png"); + if let Some(n) = opt(rest, "--bench").and_then(|v| v.parse::().ok()) { + bench(&s, cid, t, RenderOpts { scale, ..Default::default() }, n.max(1)); + } + if let Some(n) = opt(rest, "--bench-play").and_then(|v| v.parse::().ok()) { + bench_play(&s, cid, t, RenderOpts { scale, ..Default::default() }, n.max(1)); + } let t0 = std::time::Instant::now(); let img = s.render(cid, t, RenderOpts { scale, ..Default::default() }); let el = t0.elapsed(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa25..efe5ae3e8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -222,6 +222,26 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_ g } +/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated +/// parameters). The renderer's layer cache folds the layer time into the key for these. +/// The `time_dependence_is_declared` test checks the list against every registered effect. +pub const TIME_DEPENDENT: &[&str] = &[ + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.generate.radiowaves", + "ec.stylize.scatter", + "ec.stylize.strobe", + "ec.noise.noise", + "ec.noise.addgrain", + "ec.noise.noisealpha", + "ec.noise.noisehlsauto", +]; + +/// See [`TIME_DEPENDENT`]. +pub fn is_time_dependent(id: &str) -> bool { + TIME_DEPENDENT.contains(&id) +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) @@ -265,4 +285,34 @@ mod tests { assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id); } } + + /// Effects that read the clock must be listed in `TIME_DEPENDENT`, or the layer cache would + /// serve stale pixels. Render every effect at two times with default parameters (plus + /// "random…" switches turned on) and require identical output from unlisted ones. + #[test] + fn time_dependence_is_declared() { + let mut img = Image::new(40, 30); + for y in 0..30 { + for x in 0..40 { + let a = if (6..34).contains(&x) && (5..25).contains(&y) { 1.0 } else { 0.3 }; + img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]); + } + } + for s in registry() { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in params.values.iter_mut() { + if k.to_lowercase().contains("random") && matches!(v, Value::Bool(_)) { + *v = Value::Bool(true); + } + } + let run = |t: f64| { + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false }; + apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let (a, b) = (run(0.0), run(1.37)); + if a != b { + assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id); + } + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18f..02d391640 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use effectcraft_project::{Comp, ItemId, Layer, LayerId, Project, Uid}; use effectcraft_raster::Image; -use effectcraft_render::{ExprHost, FootageSource, NoFootage, RenderOpts, Renderer}; +use effectcraft_render::{ExprHost, FootageSource, LayerCache, NoFootage, RenderOpts, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -135,6 +135,8 @@ pub struct Session { pub events: Vec, /// Commands executed: (id, params). pub journal: Vec<(String, Value)>, + /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). + pub layer_cache: Arc, } impl Default for Session { @@ -152,6 +154,7 @@ impl Default for Session { importer: None, events: vec![], journal: vec![], + layer_cache: Arc::new(LayerCache::default()), } } } @@ -303,6 +306,7 @@ impl Session { pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); + r.cache = Some(&self.layer_cache); r.comp_frame(comp, t) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52f..2a47d87a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18,6 +18,35 @@ fn demo_opens_and_renders() { assert!(lit > 100_000, "{lit}"); } +/// The session's layer cache never changes pixels: scrubbing the demo (including back and +/// forth, and across an edit made through a command) matches cache-less renders exactly. +#[test] +fn demo_layer_cache_is_transparent() { + let mut s = demo(); + let cid = s.active_comp_id().unwrap(); + let opts = effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }; + let uncached = |s: &Session, t: f64| { + let mut r = effectcraft_render::Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.comp_frame(cid, effectcraft_time::Tick::from_seconds_f64(t)) + }; + let check = |s: &Session, t: f64| { + let a = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), opts); + let b = uncached(s, t); + assert!(a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|c| (p[c] - q[c]).abs() < 1e-6)), "t={t}"); + }; + for t in [0.5, 1.0, 3.0, 3.0334, 6.0, 3.0, 0.5] { + check(&s, t); + } + assert!(s.layer_cache.stats().hits > 0); + // Edit the title's glow through the command layer, then scrub again. + let title = s.project.comp(cid).unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap(); + s.execute("prop.set", json!({"layer": title, "path": "effects/#1/radius", "value": 5.0})).unwrap(); + for t in [3.0, 0.5] { + check(&s, t); + } +} + #[test] fn every_command_has_unique_id_and_runs_or_reports() { let mut ids = std::collections::HashSet::new(); diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 5a8412f38..1c065d77b 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -1,20 +1,23 @@ //! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional //! and radial (spin/zoom) blurs. +//! +//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over +//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two +//! buffers so a Gaussian allocates two images regardless of the number of passes. use rayon::prelude::*; use crate::{Image, Px}; -/// One horizontal box pass of radius `r` (window 2r+1) over each row; `repeat` = edge pixels -/// extend, otherwise transparent. -fn box_h(img: &Image, r: usize, repeat: bool) -> Image { +/// One horizontal box pass of radius `r` (window 2r+1) over each row of `src` (width `w`) into +/// `out`; `repeat` = edge pixels extend, otherwise transparent. +fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) { if r == 0 { - return img.clone(); + out.copy_from_slice(src); + return; } - let w = img.width as usize; - let mut out = Image::new(img.width, img.height); let norm = 1.0 / (2 * r + 1) as f32; - out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(o, row)| { + out.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, row)| { let get = |i: isize| -> Px { if i < 0 { if repeat { row[0] } else { [0.0; 4] } @@ -40,18 +43,89 @@ fn box_h(img: &Image, r: usize, repeat: bool) -> Image { } } }); - out } -fn transpose(img: &Image) -> Image { - let (w, h) = (img.width as usize, img.height as usize); - let mut out = Image::new(img.height, img.width); - out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { - for y in 0..h { - col[y] = img.data[y * w + x]; +/// One vertical box pass (same semantics as [`box_h_into`] along columns). +fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: bool) { + if r == 0 { + out.copy_from_slice(src); + return; + } + let norm = 1.0 / (2 * r + 1) as f32; + let block = (4 * r).clamp(32, 256); + let row = |i: isize| -> Option<&[Px]> { + if i < 0 { + repeat.then(|| &src[0..w]) + } else if i as usize >= h { + repeat.then(|| &src[(h - 1) * w..h * w]) + } else { + Some(&src[i as usize * w..(i as usize + 1) * w]) + } + }; + out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| { + let ya = (bi * block) as isize; + let mut acc = vec![[0.0f32; 4]; w]; + for i in ya - r as isize..=ya + r as isize { + if let Some(rw) = row(i) { + for (a, p) in acc.iter_mut().zip(rw) { + for c in 0..4 { + a[c] += p[c]; + } + } + } + } + for (yy, o) in chunk.chunks_mut(w).enumerate() { + let y = ya + yy as isize; + for (o, a) in o.iter_mut().zip(&acc) { + *o = a.map(|v| v * norm); + } + match (row(y + r as isize + 1), row(y - r as isize)) { + (Some(add), Some(sub)) => { + for ((a, p), q) in acc.iter_mut().zip(add).zip(sub) { + for c in 0..4 { + a[c] += p[c] - q[c]; + } + } + } + (Some(add), None) => { + for (a, p) in acc.iter_mut().zip(add) { + for c in 0..4 { + a[c] += p[c]; + } + } + } + (None, Some(sub)) => { + for (a, q) in acc.iter_mut().zip(sub) { + for c in 0..4 { + a[c] -= q[c]; + } + } + } + (None, None) => {} + } } }); - out +} + +/// Run horizontal passes with radii `rx` then vertical passes with radii `ry`. +fn box_passes(img: &Image, rx: &[usize], ry: &[usize], repeat: bool) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + let passes: Vec<(bool, usize)> = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0).collect(); + if passes.is_empty() || w == 0 || h == 0 { + return img.clone(); + } + let mut a = Image::new(img.width, img.height); + let mut b = Image::new(img.width, img.height); + for (i, &(vertical, r)) in passes.iter().enumerate() { + let src: &[Px] = if i == 0 { &img.data } else { &a.data }; + if vertical { + box_v_into(src, &mut b.data, w, h, r, repeat); + } else { + box_h_into(src, &mut b.data, w, r, repeat); + } + std::mem::swap(&mut a, &mut b); + } + a } /// Box radii for `n` passes approximating a Gaussian of `sigma` (Kovesi / "boxes for Gauss"). @@ -72,36 +146,15 @@ fn box_radii(sigma: f64, n: usize) -> Vec { /// Separable box blur with given radii, `iterations` passes. pub fn box_blur(img: &Image, rx: usize, ry: usize, iterations: usize, repeat: bool) -> Image { - let mut cur = img.clone(); - for _ in 0..iterations.max(1) { - cur = box_h(&cur, rx, repeat); - } - if ry > 0 { - let mut t = transpose(&cur); - for _ in 0..iterations.max(1) { - t = box_h(&t, ry, repeat); - } - cur = transpose(&t); - } - cur + let n = iterations.max(1); + box_passes(img, &vec![rx; n], &vec![ry; n], repeat) } /// Gaussian blur with standard deviations in pixels (3 box passes per axis). pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image { - let mut cur = img.clone(); - if sigma_x > 0.05 { - for r in box_radii(sigma_x, 3) { - cur = box_h(&cur, r, repeat); - } - } - if sigma_y > 0.05 { - let mut t = transpose(&cur); - for r in box_radii(sigma_y, 3) { - t = box_h(&t, r, repeat); - } - cur = transpose(&t); - } - cur + let rx = if sigma_x > 0.05 { box_radii(sigma_x, 3) } else { vec![] }; + let ry = if sigma_y > 0.05 { box_radii(sigma_y, 3) } else { vec![] }; + box_passes(img, &rx, &ry, repeat) } /// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from @@ -182,6 +235,54 @@ mod tests { assert!(b.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); } + /// Direct (windowed sum) box filter along x or y: the definition the passes must match. + fn box_ref(img: &Image, r: usize, vertical: bool, repeat: bool) -> Image { + let mut out = Image::new(img.width, img.height); + let (w, h) = (img.width as i64, img.height as i64); + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f64; 4]; + for k in -(r as i64)..=(r as i64) { + let (sx, sy) = if vertical { (x, y + k) } else { (x + k, y) }; + let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; + for c in 0..4 { + acc[c] += p[c] as f64; + } + } + out.set(x as u32, y as u32, acc.map(|v| (v / (2 * r + 1) as f64) as f32)); + } + } + out + } + + #[test] + fn passes_match_direct_box_filter() { + let mut img = Image::new(53, 301); + for y in 0..301 { + for x in 0..53 { + let v = crate::hash_noise(x, y, 3); + img.set(x, y, [v * 0.5, v * 0.25, v, v]); + } + } + for repeat in [false, true] { + for (rx, ry) in [(0, 3), (2, 0), (5, 9), (40, 70), (1, 200)] { + let fast = box_passes(&img, &[rx, rx + 1], &[ry, ry / 2], repeat); + let mut slow = img.clone(); + for r in [rx, rx + 1] { + slow = box_ref(&slow, r, false, repeat); + } + for r in [ry, ry / 2] { + slow = box_ref(&slow, r, true, repeat); + } + for (i, (p, q)) in fast.data.iter().zip(&slow.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "r {rx},{ry} repeat {repeat} px {i}: {p:?} vs {q:?}"); + } + } + } + } + } + #[test] fn radii_sane() { let r = box_radii(10.0, 3); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index ed9dd8980..643de9e7f 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -10,7 +10,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use rayon::prelude::*; pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur}; -pub use warp::{Sampling, WarpOpts, composite_warp, resample}; +pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; pub type Px = [f32; 4]; diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index 157539543..8b45761e4 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_geom::{Mat3, Rect, vec2}; use rayon::prelude::*; -use crate::{Image, hash_noise}; +use crate::{Image, Px, hash_noise}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sampling { @@ -52,7 +52,163 @@ fn half(src: &Image) -> Image { } /// Draw `src` transformed by `m` (src pixel space → dst pixel space) onto `dst` with blending. +/// +/// Affine transforms with non-stencil modes take a fast path (per-row span clipping, interior +/// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it +/// matches [`composite_warp_reference`] to float rounding. pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { + composite_warp_impl(dst, src, m, opts, true, None); +} + +/// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate +/// straight into one buffer instead of one full-size image per sample. +pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) { + let opts = WarpOpts { sampling, ..Default::default() }; + if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) { + composite_warp_impl(dst, src, m, &opts, true, Some(weight)); + return; + } + let mut tmp = Image::new(dst.width, dst.height); + composite_warp(&mut tmp, src, m, &opts); + dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| { + for c in 0..4 { + d[c] += s[c] * weight; + } + }); +} + +/// The straightforward per-pixel implementation (reference for the fast path's tests). +pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { + composite_warp_impl(dst, src, m, opts, false, None); +} + +/// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]). +#[inline(always)] +fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px { + let mut o = [0.0; 4]; + for i in 0..4 { + let top = a[i] + (b[i] - a[i]) * tx; + let bot = c[i] + (d[i] - c[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o +} + +/// Bilinear sample with an unchecked interior fast path. +#[inline(always)] +fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let (xi, yi) = (x0 as i64, y0 as i64); + let (w, h) = (img.width as i64, img.height as i64); + if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h { + let tx = (fx - x0) as f32; + let ty = (fy - y0) as f32; + let i = yi as usize * w as usize + xi as usize; + let row0 = &img.data[i..i + 2]; + let row1 = &img.data[i + w as usize..i + w as usize + 2]; + return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty); + } + img.sample_bilinear(x, y) +} + +/// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]` +/// (conservative by one step on each side; callers still check per pixel). +fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) { + if s.abs() < 1e-12 { + return if p0 >= min - 1e-9 && p0 <= max + 1e-9 { (0, n) } else { (0, 0) }; + } + let (a, b) = ((min - p0) / s, (max - p0) / s); + let (a, b) = if a < b { (a, b) } else { (b, a) }; + let lo = (a.floor() - 1.0).max(0.0); + let hi = (b.ceil() + 2.0).min(n as f64); + if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) } +} + +/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. +#[allow(clippy::too_many_arguments)] +fn affine_rows( + dst: &mut Image, + img: &Image, + inv: &Mat3, + sampling: Sampling, + op: f32, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, +) { + let w = dst.width as usize; + let m = inv.0; + let (iw, ih) = (img.width as f64, img.height as f64); + let step = vec2(m[0][0], m[1][0]); + // Pure integer translation: every sample lands on a pixel centre, so all filters return the + // source pixel itself (bicubic still clamps negatives, as in the reference). + let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0; + dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { + let fy = y as f64 + 0.5; + let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); + let n = x1 - x0; + let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); + let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); + let (lo, hi) = (ax.max(ay), bx.min(by)); + if lo >= hi { + return; + } + if int_tx { + let sy = y as i64 + m[1][2] as i64; + if sy < 0 || sy >= img.height as i64 { + return; + } + let dx = m[0][2] as i64; + let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; + let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; + let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; + for x in xa..xb.max(xa) { + let mut s = srow[(x as i64 + dx) as usize]; + if sampling == Sampling::Bicubic { + s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; + } + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + } + return; + } + for i in lo..hi { + let x = x0 + i; + let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); + if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { + continue; + } + let mut s = match sampling { + Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64), + Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y), + Sampling::Bicubic => img.sample_bicubic(p.x, p.y), + }; + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + } + }); +} + +fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option) { if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 { return; } @@ -91,6 +247,26 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { let affine = inv.is_affine(); let mode = opts.mode; let op = opts.opacity; + if let Some(k) = accumulate + && affine + { + affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]); + return; + } + if allow_fast && affine && !mode.is_stencil() { + match mode { + BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| { + let k = 1.0 - s[3]; + [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] + }), + BlendMode::Dissolve | BlendMode::DancingDissolve => { + let seed = opts.seed; + affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + } + _ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + } + return; + } dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { let fy = y as f64 + 0.5; let mut sp = inv.apply(vec2(x0 as f64 + 0.5, fy)); @@ -172,6 +348,78 @@ mod tests { assert_eq!(dst.get(3, 2), [1.0; 4]); } + fn test_image(w: u32, h: u32, seed: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let a = if (x + y) % 7 == 0 { 0.0 } else { hash_noise(x, y, seed) }; + img.set(x, y, [hash_noise(x, y, seed + 1) * a, hash_noise(x, y, seed + 2) * a, hash_noise(x, y, seed + 3) * a, a]); + } + } + img + } + + /// The fast affine path equals the reference per-pixel path (modes, filters, transforms). + #[test] + fn fast_path_matches_reference() { + let src = test_image(37, 23, 5); + let mats = [ + Mat3::IDENTITY, + Mat3::translate(vec2(5.0, -3.0)), + Mat3::translate(vec2(-40.0, 2.0)), + Mat3::translate(vec2(3.25, 7.5)), + Mat3::translate(vec2(20.0, 10.0)) * Mat3::rotate_deg(23.0) * Mat3::scale(vec2(1.3, 0.8)), + Mat3::translate(vec2(30.0, 5.0)) * Mat3::rotate_deg(-120.0) * Mat3::scale(vec2(0.7, 0.7)), + Mat3::translate(vec2(10.0, 10.0)) * Mat3::scale(vec2(-1.0, 1.0)), + Mat3::scale(vec2(0.3, 0.3)), + ]; + let modes = [BlendMode::Normal, BlendMode::Add, BlendMode::Multiply, BlendMode::Screen, BlendMode::Dissolve, BlendMode::Overlay]; + for (mi, m) in mats.iter().enumerate() { + for mode in modes { + for sampling in [Sampling::Nearest, Sampling::Bilinear, Sampling::Bicubic] { + for opacity in [1.0, 0.6] { + let opts = WarpOpts { sampling, opacity, mode, seed: 9, clip: None }; + let mut a = test_image(48, 40, 77); + let mut b = a.clone(); + composite_warp(&mut a, &src, m, &opts); + composite_warp_reference(&mut b, &src, m, &opts); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} {sampling:?} op {opacity} px {i}: {p:?} vs {q:?}"); + } + } + } + } + } + } + } + + /// Accumulating sub-samples equals warping each into its own image and summing. + #[test] + fn accumulate_matches_per_sample_images() { + let src = test_image(30, 20, 11); + let persp = Mat3::square_to_quad([vec2(5.0, 4.0), vec2(40.0, 2.0), vec2(44.0, 30.0), vec2(2.0, 35.0)]) * Mat3::scale(vec2(1.0 / 30.0, 1.0 / 20.0)); + let mats = [Mat3::translate(vec2(3.0, 4.0)), Mat3::translate(vec2(5.5, 4.25)) * Mat3::rotate_deg(12.0), persp]; + let k = 1.0 / mats.len() as f32; + let mut fast = Image::new(48, 40); + let mut slow = Image::new(48, 40); + for m in &mats { + accumulate_warp(&mut fast, &src, m, Sampling::Bilinear, k); + let mut img = Image::new(48, 40); + composite_warp_reference(&mut img, &src, m, &WarpOpts::default()); + for (a, b) in slow.data.iter_mut().zip(&img.data) { + for c in 0..4 { + a[c] += b[c] * k; + } + } + } + for (p, q) in fast.data.iter().zip(&slow.data) { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "{p:?} vs {q:?}"); + } + } + } + #[test] fn downscale_preserves_average() { let src = Image::filled(64, 64, [0.25, 0.5, 0.75, 1.0]); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs new file mode 100644 index 000000000..78ff39b58 --- /dev/null +++ b/crates/render/src/cache.rs @@ -0,0 +1,245 @@ +//! Layer render cache. +//! +//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the +//! layer's own non-transform properties, its source item and the render options — not on its +//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed +//! by a content hash of exactly those inputs, *evaluated at the frame time*, so: +//! +//! * a static layer (or one whose only animation is in Transform) renders once and is reused on +//! every frame while scrubbing or playing; +//! * editing one layer re-renders only that layer — every other layer's cached pixels stay valid +//! because their keys do not change; +//! * any change to a property value, keyframe, expression result, effect, mask, switch or source +//! item changes the key, so stale pixels are never returned. +//! +//! Layers whose pixels depend on time in ways the property values do not capture (time-based +//! effects such as Noise or Wave Warp, Wiggle Paths, footage, precomps) either fold the layer +//! time into the key or are not cached. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use effectcraft_effects::Buf; +use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; + +use crate::eval::EvalCtx; + +/// Thread-safe, memory-budgeted cache of processed layer buffers. +pub struct LayerCache { + inner: Mutex, +} + +struct Entry { + buf: Arc, + bytes: usize, + last_use: u64, +} + +struct Inner { + map: HashMap, + bytes: usize, + budget: usize, + clock: u64, + hits: u64, + misses: u64, +} + +/// Hit/miss counters and memory use (for perf readouts and tests). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CacheStats { + pub entries: usize, + pub bytes: usize, + pub hits: u64, + pub misses: u64, +} + +impl Default for LayerCache { + fn default() -> Self { + LayerCache::new(1 << 30) + } +} + +impl LayerCache { + /// A cache holding at most `budget` bytes of pixels. + pub fn new(budget: usize) -> LayerCache { + LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) } + } + + pub fn get(&self, key: u64) -> Option> { + let mut g = self.inner.lock().ok()?; + g.clock += 1; + let now = g.clock; + match g.map.get_mut(&key) { + Some(e) => { + e.last_use = now; + let b = e.buf.clone(); + g.hits += 1; + Some(b) + } + None => { + g.misses += 1; + None + } + } + } + + pub fn insert(&self, key: u64, buf: Arc) { + let bytes = buf.img.data.len() * std::mem::size_of::(); + let Ok(mut g) = self.inner.lock() else { return }; + if bytes > g.budget / 4 { + return; + } + g.clock += 1; + let now = g.clock; + if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { + g.bytes -= old.bytes; + } + g.bytes += bytes; + if g.bytes > g.budget { + // Evict least-recently used entries down to 3/4 of the budget. + let target = g.budget / 4 * 3; + let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); + by_age.sort_unstable(); + for (_, k) in by_age { + if g.bytes <= target { + break; + } + if let Some(e) = g.map.remove(&k) { + g.bytes -= e.bytes; + } + } + } + } + + pub fn clear(&self) { + if let Ok(mut g) = self.inner.lock() { + g.map.clear(); + g.bytes = 0; + } + } + + pub fn stats(&self) -> CacheStats { + self.inner.lock().map(|g| CacheStats { entries: g.map.len(), bytes: g.bytes, hits: g.hits, misses: g.misses }).unwrap_or_default() + } +} + +/// FNV-1a style 64-bit hasher fed by `Debug` output (exact float formatting) and raw values. +struct KeyHasher(u64); + +impl Hasher for KeyHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 ^= b as u64; + self.0 = self.0.wrapping_mul(0x0100_0000_01b3); + } + } +} + +impl std::fmt::Write for KeyHasher { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + Hasher::write(self, s.as_bytes()); + Ok(()) + } +} + +fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) { + use std::fmt::Write; + let _ = write!(h, "{v:?}"); + Hasher::write_u8(h, 0xff); +} + +/// Time-based layer content: does any part of the source/effects read the clock directly +/// (rather than through property values)? +fn time_dependent(layer: &Layer) -> bool { + if layer.switches.effects + && let Some(fx) = layer.effects() + { + for g in fx.groups() { + if let effectcraft_project::GroupKind::Effect { effect } = &g.kind + && g.enabled + && effectcraft_effects::is_time_dependent(effect) + { + return true; + } + } + } + if matches!(layer.source, LayerSource::Shape) + && let Some(c) = layer.props.sub("contents") + { + return group_has(c, "wiggle"); + } + false +} + +fn group_has(g: &PropGroup, match_id: &str) -> bool { + g.children.iter().any(|c| match c { + Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), + Node::Prop(_) => false, + }) +} + +/// Feed every property of `g` that can change over time (keyframes or expression) as its value +/// at the context time. The static structure is hashed separately. +fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { + for c in &g.children { + match c { + Node::Prop(p) => { + if !p.keys.is_empty() || p.has_expression() { + p.uid.hash(h); + hash_debug(h, &ctx.value(layer, p)); + } + } + Node::Group(sub) => hash_values(h, ctx, layer, sub), + } + } +} + +/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context +/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment +/// layers…). +pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { + if layer.switches.adjustment { + return None; + } + let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); + match &layer.source { + LayerSource::Solid { item } => { + let it = ctx.project.item(*item)?; + let ItemKind::Solid(s) = &it.kind else { return None }; + hash_debug(&mut h, s); + } + LayerSource::Text | LayerSource::Shape => {} + _ => return None, + } + ctx.comp_id.hash(&mut h); + ctx.comp.width.hash(&mut h); + ctx.comp.height.hash(&mut h); + scale.to_bits().hash(&mut h); + draft.hash(&mut h); + layer.id.hash(&mut h); + hash_debug(&mut h, &layer.source); + hash_debug(&mut h, &layer.switches); + for c in &layer.props.children { + if let Node::Group(g) = c + && g.match_id == "transform" + { + continue; + } + // Static structure: values, keyframes, expressions, enabled flags, effect ids, modes. + hash_debug(&mut h, c); + if let Node::Group(g) = c { + hash_values(&mut h, ctx, layer, g); + } + } + if time_dependent(layer) { + layer.layer_time(ctx.time).0.hash(&mut h); + // Effects also see the layer's time mapping. + layer.start_time.0.hash(&mut h); + layer.stretch.to_bits().hash(&mut h); + } + Some(h.finish()) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd3356..ef2217340 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,7 @@ //! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity. //! Adjustment layers run their effects on everything below, limited by their own bounds/masks. +pub mod cache; pub mod eval; pub mod masks; pub mod shapes; @@ -13,6 +14,7 @@ pub mod text; use std::sync::Arc; +pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, Params}; use effectcraft_geom::{Mat3, vec2}; @@ -59,13 +61,32 @@ pub struct Renderer<'a> { pub footage: &'a dyn FootageSource, pub expr: Option<&'a dyn ExprHost>, pub opts: RenderOpts, + /// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything. + pub cache: Option<&'a LayerCache>, + /// Optional per-layer timing sink (`frame --bench` / perf readouts). + pub profile: Option<&'a std::sync::Mutex>>, /// Current nesting depth (precomp recursion guard). depth: usize, } +/// Time spent on one layer of one frame (milliseconds). +#[derive(Clone, Debug, Default)] +pub struct LayerTiming { + pub depth: usize, + pub layer: String, + /// Source + masks + effects (cache lookup only, on a hit). + pub process_ms: f64, + /// Per effect: (effect id, ms). Empty on a cache hit. + pub effects: Vec<(String, f64)>, + /// Transform + matte + blend. + pub composite_ms: f64, + /// The processed buffer came from the layer cache. + pub cached: bool, +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { - Renderer { project, footage, expr: None, opts, depth: 0 } + Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 } } fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { @@ -126,7 +147,11 @@ impl<'a> Renderer<'a> { p } - fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf { + fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf { + self.apply_effects_timed(ctx, layer, buf, adjustment, None) + } + + fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf { if !layer.switches.effects { return buf; } @@ -142,7 +167,11 @@ impl<'a> Renderer<'a> { let Some(spec) = effectcraft_effects::find(effect) else { continue }; let params = self.effect_params(ctx, layer, g); let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment }; + let t0 = std::time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); + if let Some(v) = timing.as_deref_mut() { + v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); + } } buf } @@ -165,7 +194,7 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 }; + let sub = Renderer { depth: self.depth + 1, ..*self }; let lt = layer.layer_time(ctx.time); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } @@ -193,10 +222,28 @@ impl<'a> Renderer<'a> { } /// Fully processed layer buffer (source → masks → effects). - pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// Served from the layer cache when the layer's content key matches. + pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + self.layer_buf_timed(ctx, layer, None) + } + + fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option> { + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + if let Some(t) = timing.as_deref_mut() { + t.cached = true; + } + return Some(b); + } let mut buf = self.source(ctx, layer)?; masks::apply(ctx, layer, &mut buf); - Some(self.apply_effects(ctx, layer, buf, false)) + let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects))); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, buf.clone()); + } + Some(buf) } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { @@ -232,25 +279,14 @@ impl<'a> Renderer<'a> { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; let phase = ctx.comp.shutter_phase / 360.0; + // Sub-samples sum straight into one buffer (each warp is row-parallel). let mut acc = Image::new(target.width, target.height); - let accum: Vec = (0..samples) - .into_par_iter() - .map(|i| { - let f = phase + angle * i as f64 / (samples - 1) as f64; - let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); - let m = self.buf_matrix(&sub, layer, buf); - let mut img = Image::new(target.width, target.height); - composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts }); - img - }) - .collect(); let k = 1.0 / samples as f32; - for img in &accum { - acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| { - for c in 0..4 { - a[c] += b[c] * k; - } - }); + for i in 0..samples { + let f = phase + angle * i as f64 / (samples - 1) as f64; + let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); + let m = self.buf_matrix(&sub, layer, buf); + effectcraft_raster::accumulate_warp(&mut acc, &buf.img, &m, opts.sampling, k); } target.blend_from(&acc, mode, opacity, layer.id.0 as u32); } @@ -264,16 +300,34 @@ impl<'a> Renderer<'a> { self.draw_adjustment(ctx, layer, canvas, opacity); return; } - let Some(buf) = self.layer_buf(ctx, layer) else { return }; + let Some(prof) = self.profile else { + if let Some(buf) = self.layer_buf(ctx, layer) { + self.composite_layer(ctx, layer, &buf, canvas, opacity); + } + return; + }; + let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }; + let t0 = std::time::Instant::now(); + let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return }; + let t1 = std::time::Instant::now(); + self.composite_layer(ctx, layer, &buf, canvas, opacity); + timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; + timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; + if let Ok(mut v) = prof.lock() { + v.push(timing); + } + } + + fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) { let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); let preserve = layer.preserve_transparency; if matte.is_none() && !preserve { - self.place(ctx, layer, &buf, canvas, layer.blend_mode, opacity); + self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity); return; } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); - self.place(ctx, layer, &buf, &mut iso, BlendMode::Normal, 1.0); + self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0); if let Some((m, kind)) = matte { let mut mimg = Image::new(canvas.width, canvas.height); if m.is_active_at(ctx.time) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4607486c..877e9329e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -160,3 +160,174 @@ fn effects_run_in_pipeline() { let c = img.get(100, 50); assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } + +// ------------------------------------------------------------------------------ layer cache + +fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + p.next_id = next; + for (k, v) in params { + g.prop_mut(k).unwrap().value = v.clone(); + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); +} + +/// A comp with a static effected solid, an animated-trim shape, a moving text layer with a blur. +fn cache_scene() -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); + add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]); + let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + { + let mut ids = Ids(&mut next); + let e = build::shape_ellipse(&mut ids, [60.0, 60.0], [0.0, 0.0]); + let tr = build::shape_trim(&mut ids, 0.0, 0.0, 0.0); + let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0); + let g = build::shape_group(&mut ids, "Ring", vec![e, tr, s]); + shape.props.sub_mut("contents").unwrap().children.push(g.into()); + } + p.next_id = next; + shape.props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(10.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))]; + let mut text = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() })); + text.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; + add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); + let c = p.comp_mut(cid).unwrap(); + c.layers = vec![text, shape, bg]; + (p, cid) +} + +fn assert_same(a: &crate::Image, b: &crate::Image, what: &str) { + assert_eq!((a.width, a.height), (b.width, b.height), "{what}"); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-6, "{what}: pixel {i}: {p:?} vs {q:?}"); + } + } +} + +#[test] +fn cached_frames_match_uncached() { + let (p, cid) = cache_scene(); + let cache = crate::LayerCache::default(); + for f in [0.0, 0.25, 0.5, 0.5, 1.2, 1.5, 0.25] { + let t = Tick::from_seconds_f64(f); + assert_same(&render_cached(&p, cid, t, Some(&cache)), &render_cached(&p, cid, t, None), &format!("t={f}")); + } + // bg is static, text only moves (transform), the shape is static after 1 s: lots of reuse. + let st = cache.stats(); + assert!(st.hits >= 10, "{st:?}"); +} + +#[test] +fn static_and_transform_only_layers_are_reused() { + let (p, cid) = cache_scene(); + let cache = crate::LayerCache::default(); + render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache)); + let before = cache.stats(); + render_cached(&p, cid, Tick::from_seconds_f64(0.4), Some(&cache)); + let after = cache.stats(); + // bg (static) + text (only its position animates) hit; the shape's trim animates: miss. + assert_eq!(after.hits - before.hits, 2, "{before:?} {after:?}"); + assert_eq!(after.misses - before.misses, 1, "{before:?} {after:?}"); +} + +type Edit = Box; + +/// Every kind of edit re-renders the affected layer: the cached render of the edited project +/// equals a fresh uncached render, and differs from the unedited frame. +#[test] +fn edits_invalidate_cached_layers() { + let (p, cid) = cache_scene(); + let t = Tick::from_seconds_f64(0.5); + let edits: Vec<(&str, Edit)> = vec![ + ( + "effect param value", + Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.prop_mut("effects/#1/white").unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0])), + ), + ( + "effect param keyframes", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/blurriness").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(30.0))] + }), + ), + ("effect disabled", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false)), + ("effects switch off", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[0].switches.effects = false)), + ( + "effect added", + Box::new(|p, cid| { + let mut l = p.comp_mut(cid).unwrap().layers[2].clone(); + add_effect(p, &mut l, "ec.channel.invert", &[]); + p.comp_mut(cid).unwrap().layers[2] = l; + }), + ), + ( + "shape keyframe moved", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[1].props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys[1].time = Tick::from_seconds_f64(2.0) + }), + ), + ( + "text changed", + Box::new(|p, cid| { + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("text/sourceText").unwrap().value = + Value::Text(Box::new(TextDoc { text: "XYZ".into(), size: 40.0, ..Default::default() })) + }), + ), + ( + "solid colour", + Box::new(|p, cid| { + let LayerSource::Solid { item } = p.comp(cid).unwrap().layers[2].source else { unreachable!() }; + if let ItemKind::Solid(s) = &mut p.item_mut(item).unwrap().kind { + s.color = [0.9, 0.1, 0.1]; + } + p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false; + }), + ), + ( + "mask added", + Box::new(|p, cid| { + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[2].props.sub_mut("masks").unwrap().children.push(m.into()); + }), + ), + ("layer time shifted", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[1].start_time = Tick::from_seconds_f64(0.3))), + ]; + for (what, edit) in edits { + let cache = crate::LayerCache::default(); + let orig = render_cached(&p, cid, t, Some(&cache)); + let mut q = p.clone(); + edit(&mut q, cid); + let cached = render_cached(&q, cid, t, Some(&cache)); + let fresh = render_cached(&q, cid, t, None); + assert_same(&cached, &fresh, what); + assert!(orig.data.iter().zip(&fresh.data).any(|(a, b)| (a[0] - b[0]).abs() + (a[3] - b[3]).abs() > 1e-3), "{what}: edit had no visible effect"); + } +} + +#[test] +fn time_dependent_effects_rerender_every_frame() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); + add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let cache = crate::LayerCache::default(); + let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache)); + let b = render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache)); + assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2"); + assert!(a != b, "noise must animate"); +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 3f20b2ec2..aa90e22c9 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -8,8 +8,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{ItemId, Project}; -use effectcraft_engine::render::{ExprHost, FootageSource, RenderOpts, Renderer}; +use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; +use rayon::prelude::*; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FrameKey { @@ -49,14 +50,18 @@ pub struct RenderSource { pub project: Arc, pub footage: Arc, pub expr: Option>, + pub layer_cache: Arc, } pub struct Frames { cache: Arc>, + /// Keys queued or rendering. inflight: Arc>>, + /// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first). + queue: Arc>, pool: rayon::ThreadPool, ctx: Option, - /// Last render time per frame (ms), for the Info panel / perf readout. + /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, } @@ -66,6 +71,7 @@ impl Default for Frames { Frames { cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, budget: 3 << 30 })), inflight: Arc::new(Mutex::new(HashSet::new())), + queue: Arc::new(Mutex::new(Queue::default())), pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"), ctx: None, last_ms: Arc::new(Mutex::new(0.0)), @@ -78,7 +84,7 @@ impl Default for Frames { pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage { let px: Vec = img .data - .iter() + .par_iter() .map(|p| { let a = p[3].clamp(0.0, 1.0); let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8; @@ -123,36 +129,92 @@ impl Frames { } } - /// Queue a frame (no-op if cached or already rendering). + /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, false); + } + + /// Queue the frame the viewer is showing: it jumps ahead of every prefetch job. + pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, true); + } + + /// An urgent (viewer) frame is queued or rendering. + pub fn urgent_pending(&self) -> bool { + self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false) + } + + fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { if self.is_cached(&key) { return; } + let Ok(mut q) = self.queue.lock() else { return }; + // Jobs for an older project revision can never be shown: drop them. + let before = q.jobs.len(); + q.jobs.retain(|j| j.key.revision >= key.revision); + let dropped = before - q.jobs.len(); + if dropped > 0 + && let Ok(mut inf) = self.inflight.lock() + { + inf.retain(|k| k.revision >= key.revision || q.running.contains(k)); + } + if urgent { + // Only the newest viewer frame is urgent; earlier ones become prefetch. + for j in q.jobs.iter_mut() { + j.urgent = j.key == key; + } + } { let Ok(mut inf) = self.inflight.lock() else { return }; if !inf.insert(key) { return; } } - let src = src.clone(); + q.seq += 1; + let seq = q.seq; + q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); + drop(q); + let queue = self.queue.clone(); let cache = self.cache.clone(); let inflight = self.inflight.clone(); let ctx = self.ctx.clone(); let last = self.last_ms.clone(); + // One spawn per job; each spawn renders whichever queued job matters most right now. self.pool.spawn(move || { + let job = { + let Ok(mut q) = queue.lock() else { return }; + // Urgent first (newest), then prefetch in request order. + let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i); + let Some(i) = best else { return }; + let job = q.jobs.swap_remove(i); + q.running.insert(job.key); + if job.urgent { + q.urgent_running += 1; + } + job + }; let t0 = std::time::Instant::now(); - let mut r = Renderer::new(&src.project, src.footage.as_ref(), opts); - r.expr = src.expr.as_deref(); - let img = r.comp_frame(comp, t); + let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + r.expr = job.src.expr.as_deref(); + r.cache = Some(&job.src.layer_cache); + let img = r.comp_frame(job.comp, job.t); let ci = Arc::new(to_color_image(&img)); - if let Ok(mut l) = last.lock() { + if job.urgent + && let Ok(mut l) = last.lock() + { *l = t0.elapsed().as_secs_f64() * 1000.0; } if let Ok(mut c) = cache.lock() { - c.insert(key, ci); + c.insert(job.key, ci); } if let Ok(mut inf) = inflight.lock() { - inf.remove(&key); + inf.remove(&job.key); + } + if let Ok(mut q) = queue.lock() { + q.running.remove(&job.key); + if job.urgent { + q.urgent_running -= 1; + } } if let Some(ctx) = ctx { ctx.request_repaint(); @@ -160,3 +222,21 @@ impl Frames { }); } } + +struct Job { + key: FrameKey, + src: RenderSource, + comp: ItemId, + t: Tick, + opts: RenderOpts, + urgent: bool, + seq: u64, +} + +#[derive(Default)] +struct Queue { + jobs: Vec, + running: HashSet, + urgent_running: usize, + seq: u64, +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d27..45e2239e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,7 +162,12 @@ impl EffectcraftApp { } pub fn render_source(&self) -> RenderSource { - RenderSource { project: self.session.project.clone(), footage: self.session.footage.clone(), expr: self.session.expr.clone() } + RenderSource { + project: self.session.project.clone(), + footage: self.session.footage.clone(), + expr: self.session.expr.clone(), + layer_cache: self.session.layer_cache.clone(), + } } /// The render scale used by the viewer right now. @@ -178,13 +183,26 @@ impl EffectcraftApp { FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32 } } - /// Request a frame render (no-op if cached/in flight). + /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, false); + } + + /// Request the frame on screen: rendered before any queued prefetch. + pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, true); + } + + fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview }; - self.frames.request(&self.render_source(), key, comp, t, opts); + if urgent { + self.frames.request_urgent(&self.render_source(), key, comp, t, opts); + } else { + self.frames.request(&self.render_source(), key, comp, t, opts); + } } // ---------------------------------------------------------------- playback @@ -226,7 +244,15 @@ impl EffectcraftApp { let (wa, wb) = (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); - let ahead = (self.frames_parallelism() * 2).max(4) as i64; + // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few + // frames, and only once it is done, so prefetch never delays what is on screen. + let ahead = if self.playback.playing { + (self.frames_parallelism() * 2).max(4) as i64 + } else if self.frames.urgent_pending() { + 0 + } else { + (self.frames_parallelism() / 2).max(2) as i64 + }; let mut queued = self.frames.inflight(); for k in 0..ahead * 3 { if queued >= ahead as usize { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b1ae92af1..b59158679 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -184,11 +184,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame. let ppp = ctx.pixels_per_point(); let scale = app.viewer_scale(zoom, ppp); + if !app.playback.playing { + // Queue the frame on screen before any prefetch. + app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale); + } crate::tick_playback(app, &ctx, scale); let time = app.session.time(); let frame = comp.frame_rate.frame_at(time); let key = app.frame_key(cid, frame, scale); - app.request_frame(cid, frame, scale); + app.request_frame_urgent(cid, frame, scale); if let Some(img) = app.frames.get(&key) { let stale = app.viewer_tex.as_ref().is_none_or(|(_, k)| *k != key); if stale { From 62cfa66496854272d9aabc1707ff9fc66253f5e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:38:34 -0400 Subject: [PATCH 05/32] =?UTF-8?q?M0.9:=20Project=20panel=20parity=20?= =?UTF-8?q?=E2=80=94=20sortable=20columns=20(folders=20mixed=20by=20name),?= =?UTF-8?q?=20Frame=20Rate=20column,=20Size=20=3D=20file=20size,=20AE=20du?= =?UTF-8?q?rations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project.rs | 135 +++++++++++++++++++++++---- crates/ui-egui/src/state.rs | 12 +++ 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index bba588ad4..0a4539427 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -23,10 +23,48 @@ fn item_icon(it: &Item) -> Icon { } } -fn fmt_dur(secs: f64, fps: f64) -> String { - let f = (secs * fps).round() as i64; +/// Duration as AE prints it in the Project panel header: `0:00:10:00` (h:mm:ss:ff). +pub fn fmt_dur(secs: f64, fps: f64) -> String { let fpsi = fps.round().max(1.0) as i64; - format!("0;{:02};{:02};{:02}", f / fpsi / 60, (f / fpsi) % 60, f % fpsi) + let f = (secs * fps).round() as i64; + let s = f / fpsi; + format!("{}:{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60, f % fpsi) +} + +/// File size the way AE's Size column shows it (`512 KB`, `12.3 MB`). +pub fn fmt_size(bytes: u64) -> String { + const K: f64 = 1024.0; + let b = bytes as f64; + if b < K * K { + format!("{} KB", (b / K).ceil() as u64) + } else if b < K * K * K { + format!("{:.1} MB", b / (K * K)) + } else { + format!("{:.2} GB", b / (K * K * K)) + } +} + +/// Footage file size on disk (comps, solids and folders have none). +fn file_size(it: &Item) -> Option { + match &it.kind { + ItemKind::Footage(f) => std::fs::metadata(&f.path).ok().map(|m| m.len()), + _ => None, + } +} + +/// Sort siblings by the Project panel's sort column; ties fall back to the name. +pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { + let name = |i: &Item| i.name.to_lowercase(); + items.sort_by(|a, b| { + let o = match col { + "type" => a.type_name().cmp(b.type_name()), + "size" => file_size(a).cmp(&file_size(b)), + "fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal), + _ => std::cmp::Ordering::Equal, + } + .then_with(|| name(a).cmp(&name(b))); + if desc { o.reverse() } else { o } + }); } pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -82,13 +120,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Column header. let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0)); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); - let name_w = (rect.width() * 0.48).max(140.0); + let name_w = (rect.width() * 0.40).max(120.0); let col_type = rect.min.x + name_w + 30.0; - let col_size = col_type + 76.0; - p.text(pos2(rect.min.x + 26.0, hdr.center().y), Align2::LEFT_CENTER, "Name", Tokens::ui(11.0), t.text_dim); + let col_size = col_type + 80.0; + let col_fps = col_size + 52.0; + // Clickable headers sort (click again to reverse), with AE's ▲/▼ indicator. + let cols = [ + ("name", "Name", rect.min.x + 26.0, name_w - 26.0), + ("type", "Type", col_type, 76.0), + ("size", "Size", col_size, 48.0), + ("fps", "Frame Rate", col_fps, 70.0), + ]; + for (key, label, x, w) in cols { + let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(w, hdr.height())); + let resp = ui.interact(hr, egui::Id::new(("proj-sort", key)), Sense::click()); + p.with_clip_rect(hr).text(pos2(x, hdr.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + if app.ui.project_sort == key { + let c = pos2(hr.max.x - 10.0, hdr.center().y); + let d = if app.ui.project_sort_desc { 1.0 } else { -1.0 }; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x - 4.0, c.y - 2.0 * d), pos2(c.x + 4.0, c.y - 2.0 * d), pos2(c.x, c.y + 2.5 * d)], + t.text_dim, + Stroke::NONE, + )); + } + app.auto.add(&format!("project.sort.{key}"), hr, label); + if resp.clicked() { + if app.ui.project_sort == key { + app.ui.project_sort_desc = !app.ui.project_sort_desc; + } else { + app.ui.project_sort = key.to_string(); + app.ui.project_sort_desc = false; + } + } + } icons::paint(&p, Rect::from_center_size(pos2(rect.min.x + name_w + 14.0, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); - p.text(pos2(col_type, hdr.center().y), Align2::LEFT_CENTER, "Type", Tokens::ui(11.0), t.text_dim); - p.text(pos2(col_size, hdr.center().y), Align2::LEFT_CENTER, "Size", Tokens::ui(11.0), t.text_dim); // Rows. let footer_h = 28.0; let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); @@ -97,7 +163,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let query = app.ui.project_search.to_lowercase(); let mut rows: Vec<(ItemId, usize)> = vec![]; fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { - for it in app.session.project.children(folder) { + let mut kids = app.session.project.children(folder); + sort_items(&mut kids, &app.ui.project_sort, app.ui.project_sort_desc); + for it in kids { if !q.is_empty() && !it.is_folder() && !it.name.to_lowercase().contains(q) { continue; } @@ -140,17 +208,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - icons::paint( - &lp, - Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), - item_icon(&it), - if it.is_folder() { Color32::from_rgb(0xd8, 0xc0, 0x60) } else { t.text_dim }, + icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim); + let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 6.0, r.max.y)).intersect(list); + lp.with_clip_rect(name_clip).text( + pos2(x0 + 30.0, r.center().y), + Align2::LEFT_CENTER, + &it.name, + Tokens::ui(12.0), + if selected { Color32::WHITE } else { t.text }, ); - lp.text(pos2(x0 + 30.0, r.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(12.0), if selected { Color32::WHITE } else { t.text }); lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label)); lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim); - if let Some((w, h)) = it.dimensions() { - lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, format!("{w} x {h}"), Tokens::ui(11.5), t.text_dim); + if let Some(b) = file_size(&it) { + lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim); + } + if let Some(fr) = it.frame_rate() { + lp.text(pos2(col_fps + 66.0, r.center().y), Align2::RIGHT_CENTER, format!("{:.2}", fr.as_f64()), Tokens::ui(11.5), t.text_dim); } let resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}", id.0), r, &it.name); @@ -262,3 +335,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ae_duration_and_size_formats() { + assert_eq!(fmt_dur(10.0, 29.97), "0:00:10:00"); + assert_eq!(fmt_dur(3725.48, 25.0), "1:02:05:12"); + assert_eq!(fmt_size(1000), "1 KB"); + assert_eq!(fmt_size(5 * 1024 * 1024 + 300_000), "5.3 MB"); + } + + #[test] + fn folders_sort_with_items_by_name() { + let mut p = effectcraft_engine::project::Project::default(); + let l = effectcraft_engine::color::Label::Yellow; + p.add_item("Solids", l, None, ItemKind::Folder); + p.add_item("Precomps", l, None, ItemKind::Folder); + p.add_item("EffectCraft Intro", l, None, ItemKind::Folder); + let mut kids = p.children(None); + sort_items(&mut kids, "name", false); + let names: Vec<&str> = kids.iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, ["EffectCraft Intro", "Precomps", "Solids"]); + sort_items(&mut kids, "name", true); + assert_eq!(kids[0].name, "Solids"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 05a85121b..3662b7f9d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -268,6 +268,10 @@ impl Default for TimelineState { } } +fn default_project_sort() -> String { + "name".into() +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, @@ -286,6 +290,12 @@ pub struct UiState { pub effects_open: BTreeSet, pub project_search: String, pub project_open_folders: BTreeSet, + /// Project panel sort column (`name`, `type`, `size`, `fps`) and direction, as in AE's + /// clickable column headers. Folders sort with everything else. + #[serde(default = "default_project_sort")] + pub project_sort: String, + #[serde(default)] + pub project_sort_desc: bool, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// Shape tool options. @@ -318,6 +328,8 @@ impl Default for UiState { effects_open: BTreeSet::new(), project_search: String::new(), project_open_folders: BTreeSet::new(), + project_sort: default_project_sort(), + project_sort_desc: false, fx_closed: BTreeSet::new(), fill_color: [0.24, 0.55, 0.96], stroke_color: [1.0, 1.0, 1.0], From c672e03160087a831c7ff614ed078853f27e2e4e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:40:31 -0400 Subject: [PATCH 06/32] M14.1: MCP server + agent CLI - crates/automation (L5, no egui): hand-rolled JSON-RPC 2.0 MCP server over stdio (initialize, ping, tools/list, tools/call); headless backend (in-process Session) and bridge backend (desktop control channel). Tools: list_commands, execute_command, get_project/comp/layer, get/set_property, add_keyframe, render_frame (inline PNG), open/save_project, undo/redo; bridge-only screenshot, ui_* and raw control passthrough. - effectcraft-cli: info, commands, exec, run, props, get, set, render-frame, mcp [--bridge PORT]; --json, --project/--demo/--empty, --save/--save-as. - engine: prop.get, layer.tree node paths + time, Session::resolve_comp, render_rgba8, execute_checked (rejects unknown params, lists accepted keys). - control channel: render.frame; engine.execute runs every engine command directly (no dialogs, validated params). - docs: control-protocol.md, agents.md. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 15 +- apps/effectcraft-cli/Cargo.toml | 5 +- apps/effectcraft-cli/src/main.rs | 428 ++++++++++++++++--- apps/effectcraft-cli/tests/cli.rs | 133 ++++++ crates/automation/Cargo.toml | 16 + crates/automation/src/backend.rs | 104 +++++ crates/automation/src/base64.rs | 61 +++ crates/automation/src/bridge.rs | 99 +++++ crates/automation/src/lib.rs | 90 ++++ crates/automation/src/server.rs | 144 +++++++ crates/automation/src/tests.rs | 265 ++++++++++++ crates/automation/src/tools.rs | 609 ++++++++++++++++++++++++++++ crates/engine/src/commands/comp.rs | 4 +- crates/engine/src/commands/mod.rs | 61 ++- crates/engine/src/commands/prop.rs | 18 +- crates/engine/src/commands/query.rs | 58 ++- crates/engine/src/lib.rs | 27 ++ crates/engine/src/tests.rs | 73 ++++ crates/host/src/lib.rs | 11 +- crates/ui-egui/src/control.rs | 88 +++- docs/agents.md | 103 +++++ docs/control-protocol.md | 87 ++++ 22 files changed, 2398 insertions(+), 101 deletions(-) create mode 100644 apps/effectcraft-cli/tests/cli.rs create mode 100644 crates/automation/Cargo.toml create mode 100644 crates/automation/src/backend.rs create mode 100644 crates/automation/src/base64.rs create mode 100644 crates/automation/src/bridge.rs create mode 100644 crates/automation/src/lib.rs create mode 100644 crates/automation/src/server.rs create mode 100644 crates/automation/src/tests.rs create mode 100644 crates/automation/src/tools.rs create mode 100644 docs/agents.md create mode 100644 docs/control-protocol.md diff --git a/Cargo.lock b/Cargo.lock index b29b91818..8bcf7fb40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,14 +1054,23 @@ dependencies = [ ] [[package]] -name = "effectcraft-cli" +name = "effectcraft-automation" version = "0.1.0" dependencies = [ "effectcraft-engine", - "effectcraft-render", - "effectcraft-time", "image", "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "effectcraft-cli" +version = "0.1.0" +dependencies = [ + "effectcraft-automation", + "effectcraft-engine", + "effectcraft-host", + "serde_json", ] [[package]] diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d06..3b64105e7 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -14,7 +14,6 @@ path = "src/main.rs" [dependencies] effectcraft-engine = { workspace = true } -effectcraft-render = { workspace = true } -effectcraft-time = { workspace = true } +effectcraft-host = { workspace = true } +effectcraft-automation = { workspace = true } serde_json = { workspace = true } -image = { workspace = true } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58f..111887be2 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1,89 +1,391 @@ -//! EffectCraft headless CLI. +//! EffectCraft headless CLI: one-shot agent commands, frame rendering and the MCP server. //! //! ```text -//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png -//! effectcraft-cli run [project.ecproj | --demo] '' '' … (prints results) -//! effectcraft-cli commands (lists commands) +//! effectcraft-cli info project + engine summary +//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands +//! effectcraft-cli exec [--params JSON] run one command +//! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) +//! effectcraft-cli get [--time S] read a property +//! effectcraft-cli set [--time S] [--expression E] +//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] +//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio +//! +//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) +//! Saving: --save (back to --project) | --save-as F.ecproj +//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session +//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1) //! ``` +//! +//! `` is a comp id or name (`-` = the active comp); `` an id, `#n` or name; `` +//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`. -use effectcraft_engine::Session; -use effectcraft_render::RenderOpts; -use effectcraft_time::Tick; +use effectcraft_automation::tools::{self, Reply}; +use effectcraft_automation::{Backend, McpServer}; use serde_json::{Value, json}; -fn usage() -> ! { - eprintln!("usage: effectcraft-cli [args] (see --help in source docs)"); - std::process::exit(2) +const USAGE: &str = "usage: effectcraft-cli [args] [--json] + info project + engine summary + commands [--filter TEXT] [--enabled] list engine commands + exec [--params JSON] run one engine command + run [ ...] run several commands in order + props [--flat] [--time S] a layer's property tree with paths + get [--time S] read a property + set [--time S] [--expression E] + render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] + mcp [--bridge PORT] MCP server (JSON-RPC over stdio) +options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json +: id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; + +/// Options that take a value. +const VALUED: &[&str] = + &["--params", "--project", "--bridge", "--save-as", "--filter", "--time", "--frame", "--comp", "--max-side", "--scale", "--out", "--expression", "--depth"]; + +struct Args { + pos: Vec, + opts: Vec<(String, Option)>, + /// `--project F` or a positional `*.ecproj`. + project: Option, } -fn open(s: &mut Session, args: &[String]) { - if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) { - if let Err(e) = s.execute("file.open", json!({"path": p})) { - eprintln!("effectcraft: {e}"); - std::process::exit(1); +impl Args { + fn parse(raw: Vec) -> Result { + let (mut pos, mut opts) = (vec![], vec![]); + let mut it = raw.into_iter(); + while let Some(a) = it.next() { + if a.starts_with("--") && a.len() > 2 { + let (k, inline) = match a.split_once('=') { + Some((k, v)) => (k.to_string(), Some(v.to_string())), + None => (a, None), + }; + if VALUED.contains(&k.as_str()) { + let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?; + opts.push((k, Some(v))); + } else { + opts.push((k, None)); + } + } else { + pos.push(a); + } } - } else if args.iter().any(|a| a == "--demo") || true { - let _ = s.execute("file.openDemoProject", json!({})); + let mut a = Args { pos, opts, project: None }; + a.project = match a.opt("--project") { + Some(p) => Some(p.to_string()), + None => a.pos.iter().position(|x| x.ends_with(".ecproj")).map(|i| a.pos.remove(i)), + }; + Ok(a) + } + fn flag(&self, k: &str) -> bool { + self.opts.iter().any(|(o, _)| o == k) + } + fn opt(&self, k: &str) -> Option<&str> { + self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref()) + } + fn num(&self, k: &str) -> Result, String> { + self.opt(k).map(|v| v.parse::().map_err(|_| format!("{k}: not a number: {v}"))).transpose() } } -fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> { - args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str) +/// A comp/layer reference: number → id, `-` → none (active), else a name / `#n`. +fn reference(s: &str) -> Option { + match s { + "-" | "" => None, + _ => Some(s.parse::().map(Value::from).unwrap_or_else(|_| json!(s))), + } +} + +/// JSON if it parses, else the bare string. +fn value_arg(s: &str) -> Value { + serde_json::from_str(s).unwrap_or_else(|_| json!(s)) } fn main() { - let args: Vec = std::env::args().skip(1).collect(); - let Some(cmd) = args.first().cloned() else { usage() }; - let rest = &args[1..]; - let mut s = Session::default(); - match cmd.as_str() { - "frame" => { - open(&mut s, rest); - if let Some(c) = opt(rest, "--comp") { - let _ = s.execute("comp.open", json!({"comp": c})); + let raw: Vec = std::env::args().skip(1).collect(); + if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") { + println!("{USAGE}"); + std::process::exit(if raw.is_empty() { 2 } else { 0 }); + } + let mut args = match Args::parse(raw) { + Ok(a) => a, + Err(e) => fail_usage(&e), + }; + let json_out = args.flag("--json"); + if args.pos.is_empty() { + fail_usage("missing subcommand"); + } + let cmd = args.pos.remove(0); + match run(&cmd, &args, json_out) { + Ok(()) => {} + Err(Failure::Usage(e)) => fail_usage(&e), + Err(Failure::Error(e)) => { + if json_out { + println!("{}", json!({"error": e})); } - let cid = s.active_comp_id().unwrap_or_else(|| { - eprintln!("no composition"); - std::process::exit(1) - }); - let comp = s.project.comp(cid).cloned().unwrap_or_else(|| std::process::exit(1)); - let t = if let Some(f) = opt(rest, "--frame").and_then(|f| f.parse().ok()) { - comp.frame_rate.tick_of(f) - } else { - opt(rest, "--time").and_then(|v| v.parse().ok()).map(Tick::from_seconds_f64).unwrap_or(s.time()) - }; - let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0); - let out = opt(rest, "--out").unwrap_or("frame.png"); - let t0 = std::time::Instant::now(); - let img = s.render(cid, t, RenderOpts { scale, ..Default::default() }); - let el = t0.elapsed(); - let bg = comp.background; - let rgba = img.to_rgba8_over(bg); - image::save_buffer(out, &rgba, img.width, img.height, image::ColorType::Rgba8).unwrap_or_else(|e| { - eprintln!("cannot write {out}: {e}"); - std::process::exit(1) - }); - eprintln!("rendered {}x{} at {:.3}s in {:.1} ms → {out}", img.width, img.height, t.seconds(), el.as_secs_f64() * 1000.0); + eprintln!("effectcraft-cli {cmd}: {e}"); + std::process::exit(1); } - "run" => { - open(&mut s, rest); - let pairs: Vec<&String> = rest.iter().filter(|a| !a.ends_with(".ecproj") && *a != "--demo").collect(); - for ch in pairs.chunks(2) { - let params: Value = ch.get(1).and_then(|p| serde_json::from_str(p).ok()).unwrap_or(json!({})); - match s.execute(ch[0], params) { - Ok(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()), - Err(e) => { - eprintln!("{}: {e}", ch[0]); - std::process::exit(1) + } +} + +fn fail_usage(e: &str) -> ! { + eprintln!("effectcraft-cli: {e}\n{USAGE}"); + std::process::exit(2) +} + +enum Failure { + Usage(String), + Error(String), +} +impl From for Failure { + fn from(e: effectcraft_automation::Error) -> Self { + Failure::Error(e.to_string()) + } +} +impl From for Failure { + fn from(e: String) -> Self { + Failure::Error(e) + } +} + +fn usage_err(m: &str) -> Result { + Err(Failure::Usage(m.to_string())) +} + +/// Build the backend: a bridge to the app, or a headless session with the requested project. +fn backend(args: &Args, default_demo: bool) -> Result { + if let Some(addr) = args.opt("--bridge") { + if args.project.is_some() || args.flag("--demo") { + return usage_err("--bridge drives the running app's project; use `exec file.open` to open another"); + } + return Ok(Backend::bridge(addr)?); + } + let mut b = Backend::headless(effectcraft_host::session()); + if let Some(p) = &args.project { + b.exec("file.open", json!({"path": p}))?; + } else if args.flag("--demo") || (default_demo && !args.flag("--empty")) { + b.exec("file.openDemoProject", json!({}))?; + } + Ok(b) +} + +/// `--save` / `--save-as` after a mutating command. +fn maybe_save(b: &mut Backend, args: &Args) -> Result, Failure> { + let r = if let Some(p) = args.opt("--save-as") { + b.exec("file.saveAs", json!({"path": p}))? + } else if args.flag("--save") { + b.exec("file.save", json!({})).map_err(|e| Failure::Error(format!("{e} (use --project F to save in place, or --save-as F)")))? + } else { + return Ok(None); + }; + Ok(r.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| args.opt("--save-as").map(str::to_string))) +} + +fn tool(b: &mut Backend, name: &str, a: Value) -> Result { + match tools::run(b, name, &a)? { + Reply::Json(v) => Ok(v), + Reply::Image { info, .. } => Ok(info), + } +} + +/// Print a result: compact JSON with `--json`, else pretty JSON. +fn emit(v: &Value, json_out: bool) { + if json_out { + println!("{v}"); + } else { + println!("{}", serde_json::to_string_pretty(v).unwrap_or_default()); + } +} + +fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { + match cmd { + "info" => { + let mut b = backend(args, true)?; + let p = tool(&mut b, "get_project", json!({}))?; + let comp = tool(&mut b, "get_comp", json!({})).ok(); + let info = json!({ + "version": env!("CARGO_PKG_VERSION"), + "mode": if b.is_bridge() { "bridge" } else { "headless" }, + "commands": effectcraft_engine::command_specs().len(), + "effects": effectcraft_engine::effects::registry().len(), + "project": p, + "activeComp": comp, + }); + if json_out { + emit(&info, true); + } else { + println!( + "EffectCraft {} ({}), {} commands, {} effects", + info["version"].as_str().unwrap_or(""), + info["mode"].as_str().unwrap_or(""), + info["commands"], + info["effects"] + ); + println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)")); + for i in p["items"].as_array().into_iter().flatten() { + println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or("")); + } + if let Some(c) = comp { + println!( + "active comp {} \"{}\" {}x{} @ {} fps, {} s", + c["id"], + c["name"].as_str().unwrap_or(""), + c["width"], + c["height"], + c["frameRate"], + c["duration"] + ); + for l in c["layers"].as_array().into_iter().flatten() { + println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or("")); } } } } "commands" => { - for c in effectcraft_engine::command_specs() { - println!("{:<36} {:<40} {}", c.id, c.label, c.shortcut.unwrap_or("")); + let mut b = backend(args, false)?; + let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?; + if json_out { + emit(&v, true); + } else { + for c in v.as_array().into_iter().flatten() { + println!( + "{:<36} {:<36} {:<16} {}", + c["id"].as_str().unwrap_or(""), + c["label"].as_str().unwrap_or(""), + c["shortcut"].as_str().unwrap_or(""), + c["params"].as_str().unwrap_or("") + ); + } } } - _ => usage(), + "exec" => { + let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") }; + let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) { + (Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?, + (None, None) => json!({}), + }; + let mut b = backend(args, true)?; + let r = b.exec(&id, params)?; + let saved = maybe_save(&mut b, args)?; + emit(&with_saved(r, saved), json_out); + } + "run" => { + if args.pos.is_empty() { + return usage_err("run needs [] pairs"); + } + let pairs = args.pos.clone(); + let mut b = backend(args, true)?; + let mut results = vec![]; + let mut i = 0; + while i < pairs.len() { + let id = &pairs[i]; + let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) { + Some(p) => { + i += 1; + serde_json::from_str(p).map_err(|e| Failure::Usage(format!("{id}: params are not valid JSON: {e}")))? + } + None => json!({}), + }; + i += 1; + let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; + if !json_out { + emit(&r, false); + } + results.push(json!({"command": id, "result": r})); + } + let saved = maybe_save(&mut b, args)?; + if json_out { + emit(&with_saved(json!(results), saved), true); + } + } + "props" => { + let [comp, layer] = positional::<2>(args, "props ")?; + let mut b = backend(args, true)?; + let a = json!({"comp": reference(&comp), "layer": reference(&layer), "flat": args.flag("--flat") || !json_out, "time": args.num("--time")?, "depth": args.num("--depth")?}); + let v = tool(&mut b, "get_layer", a)?; + if json_out { + emit(&v, true); + } else { + println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]); + for p in v["properties"].as_array().into_iter().flatten() { + let mut extra = String::new(); + if let Some(k) = p.get("keys") { + extra += &format!(" [{k} keys]"); + } + if let Some(e) = p.get("expression").and_then(Value::as_str) { + extra += &format!(" expr: {e}"); + } + println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]); + } + } + } + "get" => { + let [comp, layer, path] = positional::<3>(args, "get ")?; + let mut b = backend(args, true)?; + let v = tool(&mut b, "get_property", json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "time": args.num("--time")?}))?; + emit(&v, json_out); + } + "set" => { + let expr = args.opt("--expression").map(str::to_string); + let need = if expr.is_some() { 3 } else { 4 }; + if args.pos.len() < need { + return usage_err("set [--time S] [--expression E]"); + } + let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone()); + let value = args.pos.get(3).map(|v| value_arg(v)); + let mut b = backend(args, true)?; + let a = + json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr}); + let v = tool(&mut b, "set_property", a)?; + let saved = maybe_save(&mut b, args)?; + emit(&with_saved(v, saved), json_out); + } + "render-frame" | "frame" => { + let mut b = backend(args, true)?; + let comp = args.opt("--comp").and_then(reference); + let mut time = args.num("--time")?; + let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0); + let needs_info = args.opt("--frame").is_some() || args.opt("--scale").is_some(); + if needs_info { + let c = tool(&mut b, "get_comp", json!({"comp": comp}))?; + if let Some(f) = args.num("--frame")? { + time = Some(f / c["frameRate"].as_f64().unwrap_or(30.0)); + } + if let Some(k) = args.num("--scale")? { + let long = c["width"].as_f64().unwrap_or(0.0).max(c["height"].as_f64().unwrap_or(0.0)); + max_side = (long * k).round().max(1.0) as u32; + } + } + let out = args.opt("--out").unwrap_or("frame.png").to_string(); + let t0 = std::time::Instant::now(); + let f = b.render(comp.as_ref(), time, max_side)?; + let ms = t0.elapsed().as_secs_f64() * 1000.0; + std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?; + let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0}); + if json_out { + emit(&info, true); + } else { + eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time); + } + } + "mcp" => { + let b = backend(args, false)?; + McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?; + } + other => return usage_err(&format!("unknown subcommand `{other}`")), + } + Ok(()) +} + +fn positional(args: &Args, usage: &str) -> Result<[String; N], Failure> { + if args.pos.len() < N { + return usage_err(usage); + } + Ok(std::array::from_fn(|i| args.pos[i].clone())) +} + +fn with_saved(v: Value, saved: Option) -> Value { + match saved { + None => v, + Some(p) => json!({"result": v, "saved": p}), } } diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs new file mode 100644 index 000000000..c84277202 --- /dev/null +++ b/apps/effectcraft-cli/tests/cli.rs @@ -0,0 +1,133 @@ +//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio. + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) +} + +/// Run with `--json`; returns (exit code, parsed stdout). +fn run_json(args: &[&str]) -> (i32, Value) { + let out = bin().args(args).arg("--json").output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let v = + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: not JSON ({e}): {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr))); + (out.status.code().unwrap_or(-1), v) +} + +fn ok_json(args: &[&str]) -> Value { + let (code, v) = run_json(args); + assert_eq!(code, 0, "{args:?}: {v}"); + v +} + +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +#[test] +fn info_and_commands() { + let v = ok_json(&["info"]); + assert_eq!(v["mode"], "headless"); + assert!(v["commands"].as_u64().unwrap() > 50); + assert!(v["activeComp"]["layers"].as_array().unwrap().len() > 1, "demo project by default"); + + let v = ok_json(&["commands", "--filter", "layer.new"]); + let ids: Vec<&str> = v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect(); + assert!(ids.contains(&"layer.newSolid") && ids.contains(&"layer.newText")); + assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty())); +} + +#[test] +fn exec_set_get_with_saved_project() { + let proj = tmp("edit.ecproj"); + let p = proj.to_str().unwrap(); + let v = ok_json(&["exec", "comp.new", "--params", r#"{"name":"Main","width":320,"height":180,"duration":3}"#, "--empty", "--save-as", p]); + assert_eq!(v["saved"], p); + // Positional params + positional project + in-place save. + let v = ok_json(&["exec", "layer.newSolid", r##"{"color":"#00ff00","name":"Green"}"##, p, "--save"]); + assert!(v["result"]["layer"].is_u64()); + + let v = ok_json(&["set", "Main", "Green", "transform/opacity", "25", "--project", p, "--save"]); + assert_eq!(v["result"]["value"], 25.0); + ok_json(&["set", "Main", "#1", "transform/position", "[0,90]", "--time", "0", p, "--save"]); + ok_json(&["set", "Main", "#1", "transform/position", "[320,90]", "--time", "2", p, "--save"]); + + let v = ok_json(&["get", "Main", "#1", "transform/position", "--time", "1", p]); + assert_eq!(v["keys"].as_array().unwrap().len(), 2); + assert!((v["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{v}"); + assert_eq!(ok_json(&["get", "-", "Green", "transform/opacity", p])["value"], 25.0); + + let v = ok_json(&["props", "Main", "#1", p]); + assert_eq!(v["name"], "Green"); + let tree = v["properties"]["children"].as_array().unwrap(); + assert!(!tree.is_empty()); + // The human (flat) listing shows paths. + let out = bin().args(["props", "Main", "#1", p]).output().unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("transform/position")); + + // Several commands in one process. + let v = ok_json(&["run", p, "time.set", r#"{"time":1}"#, "project.summary"]); + assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); +} + +#[test] +fn render_frame_writes_png() { + let out = tmp("f.png"); + let o = out.to_str().unwrap(); + let v = ok_json(&["render-frame", "--time", "1", "--max-side", "240", "--out", o]); + assert_eq!(v["width"], 240); + assert_eq!(v["height"], 135); + let png = std::fs::read(&out).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + let v = ok_json(&["render-frame", "--frame", "15", "--scale", "0.125", "--out", o]); + assert_eq!(v["width"], 240); + assert!((v["time"].as_f64().unwrap() - 0.5).abs() < 0.02, "{v}"); +} + +#[test] +fn errors_are_json() { + let (code, v) = run_json(&["exec", "no.such.command", "--empty"]); + assert_eq!(code, 1); + assert!(v["error"].as_str().unwrap().contains("unknown command")); + let (code, v) = run_json(&["exec", "layer.select", r#"{"index":2}"#]); + assert_eq!(code, 1); + assert!(v["error"].as_str().unwrap().contains("accepted: layers, add, toggle"), "{v}"); + let (code, v) = run_json(&["get", "-", "#99", "transform/opacity"]); + assert_eq!(code, 1); + assert!(v["error"].is_string()); + let out = bin().args(["get", "only-one-arg"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +#[test] +fn mcp_over_stdio() { + let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let msgs = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_property", "arguments": {"layer": "#1", "path": "transform/opacity"}}}), + json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "render_frame", "arguments": {"max_side": 64}}}), + ]; + { + let mut stdin = child.stdin.take().unwrap(); + for m in &msgs { + writeln!(stdin, "{m}").unwrap(); + } + } // EOF ends the server. + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 4, "one reply per request, none for the notification"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "effectcraft"); + assert!(replies[1]["result"]["tools"].as_array().unwrap().len() >= 13); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 000000000..a3b164080 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "effectcraft-automation" +description = "EffectCraft MCP server (JSON-RPC 2.0 over stdio) over a headless session or bridged to the running app's control channel" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-engine = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +image = { workspace = true } diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs new file mode 100644 index 000000000..ada8166bf --- /dev/null +++ b/crates/automation/src/backend.rs @@ -0,0 +1,104 @@ +//! Where tools run: an in-process session, or the desktop app over its control channel. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use serde_json::{Value, json}; + +use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; + +pub enum Backend { + /// An in-process session (no window): engine commands, inspection and rendering. + Headless(Box), + /// A running desktop app reached over its control channel: everything, plus the live UI. + Bridge(BridgeClient), +} + +/// A rendered comp frame. +pub struct Frame { + pub png: Vec, + pub comp: Value, + pub time: f64, + pub width: u32, + pub height: u32, +} + +impl Backend { + pub fn headless(session: Session) -> Self { + Backend::Headless(Box::new(session)) + } + + /// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`). + pub fn bridge(addr: &str) -> Result { + Ok(Backend::Bridge(BridgeClient::new(addr)?)) + } + + pub fn is_bridge(&self) -> bool { + matches!(self, Backend::Bridge(_)) + } + + pub fn session(&mut self) -> Option<&mut Session> { + match self { + Backend::Headless(s) => Some(s), + Backend::Bridge(_) => None, + } + } + + /// Run an engine command by id. + pub fn exec(&mut self, id: &str, params: Value) -> Result { + let params = if params.is_null() { json!({}) } else { params }; + match self { + Backend::Headless(s) => { + let r = s.execute_checked(id, params); + s.drain_events(); + Ok(r?) + } + Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), + } + } + + /// Call a raw control-channel method (bridge only). + pub fn control(&mut self, method: &str, params: Value) -> Result { + match self { + Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), + Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), + } + } + + /// Render a comp frame (comp time `time` seconds, default the CTI) as PNG, longest side at most + /// `max_side` (0 = full size). + pub fn render(&mut self, comp: Option<&Value>, time: Option, max_side: u32) -> Result { + match self { + Backend::Headless(s) => { + let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; + let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let (w, h, rgba) = s.render_rgba8(cid, t, max_side)?; + let png = encode_png(w, h, rgba, max_side)?; + Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) + } + Backend::Bridge(b) => { + let mut p = json!({"max_side": max_side, "base64": true}); + if let Some(t) = time { + p["time"] = json!(t); + } + if let Some(c) = comp.filter(|c| !c.is_null()) { + p["comp"] = c.clone(); + } + let r = b.call("render.frame", p)?; + let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?; + let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?; + let (png, width, height) = recompress_png(&raw, max_side)?; + Ok(Frame { png, comp: r["comp"].clone(), time: r["time"].as_f64().unwrap_or(0.0), width, height }) + } + } + } +} + +fn no_comp(comp: Option<&Value>) -> Error { + match comp.filter(|c| !c.is_null()) { + Some(c) => Error::BadArgs(format!("no composition {c} (see get_project)")), + None => Error::BadArgs("no active composition: create one (execute_command comp.new) or open a project".into()), + } +} + +pub const NEED_BRIDGE: &str = + "this tool drives the live app: start it with `effectcraft --control 9877` and run the MCP server as `effectcraft-cli mcp --bridge 9877`"; diff --git a/crates/automation/src/base64.rs b/crates/automation/src/base64.rs new file mode 100644 index 000000000..90002c600 --- /dev/null +++ b/crates/automation/src/base64.rs @@ -0,0 +1,61 @@ +//! Standard base64 (RFC 4648 §4, padded): MCP image content and the control channel's inline PNGs. + +const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn encode(data: &[u8]) -> String { + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for i in 0..4 { + s.push(if i <= c.len() { A[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + s +} + +/// Decode, ignoring ASCII whitespace; `None` on any other invalid input. +pub fn decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + } as u32) + }; + let bytes: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if !bytes.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for (ci, c) in bytes.chunks(4).enumerate() { + let last = ci == bytes.len() / 4 - 1; + let pad = c.iter().rev().take_while(|b| **b == b'=').count(); + if pad > 2 || (pad > 0 && !last) { + return None; + } + let mut n = 0u32; + for &b in &c[..4 - pad] { + n = n << 6 | val(b)?; + } + n <<= 6 * pad as u32; + out.extend_from_slice(&[(n >> 16) as u8, (n >> 8) as u8, n as u8][..3 - pad]); + } + Some(out) +} + +#[cfg(test)] +mod tests { + #[test] + fn rfc4648_vectors() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { + assert_eq!(super::encode(i.as_bytes()), o); + assert_eq!(super::decode(o).unwrap(), i.as_bytes()); + } + assert!(super::decode("Zm9v!").is_none()); + let all: Vec = (0..=255).collect(); + assert_eq!(super::decode(&super::encode(&all)).unwrap(), all); + } +} diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs new file mode 100644 index 000000000..eab1c01db --- /dev/null +++ b/crates/automation/src/bridge.rs @@ -0,0 +1,99 @@ +//! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`). +//! One persistent loopback connection, reconnected once if the app went away. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::{Error, Result}; + +/// Slightly longer than the app's own 60 s per-request timeout, so its error reply wins. +const READ_TIMEOUT: Duration = Duration::from_secs(65); + +pub struct BridgeClient { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl BridgeClient { + /// `addr`: a port (`9877`) or `host:port` on loopback (`127.0.0.1`, `localhost`, `[::1]`). + pub fn new(addr: &str) -> Result { + let addr = if addr.parse::().is_ok() { format!("127.0.0.1:{addr}") } else { addr.to_string() }; + let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr); + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") { + return Err(Error::BadArgs(format!("bridge address must be loopback (PORT or 127.0.0.1:PORT), got `{addr}`"))); + } + Ok(Self { addr, conn: None, next_id: 1 }) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn connect(&self) -> Result<(BufReader, TcpStream)> { + let sa = self + .addr + .to_socket_addrs() + .map_err(|e| Error::Bridge(format!("{}: {e}", self.addr)))? + .next() + .ok_or_else(|| Error::Bridge(format!("cannot resolve {}", self.addr)))?; + let s = TcpStream::connect_timeout(&sa, Duration::from_secs(5)) + .map_err(|e| Error::Bridge(format!("cannot connect to {} ({e}); start the app with `effectcraft --control `", self.addr)))?; + s.set_read_timeout(Some(READ_TIMEOUT)).ok(); + s.set_nodelay(true).ok(); + let r = s.try_clone().map_err(|e| Error::Bridge(e.to_string()))?; + Ok((BufReader::new(r), s)) + } + + /// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`]. + pub fn call(&mut self, method: &str, params: Value) -> Result { + let mut last = Error::Bridge("not attempted".into()); + for attempt in 0..2 { + if self.conn.is_none() { + self.conn = Some(self.connect()?); + } + let id = self.next_id; + self.next_id += 1; + let Some((r, w)) = self.conn.as_mut() else { continue }; + let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); + // `Ok(Err(_))`: the connection is dead before a reply (safe to resend once); + // `Err(_)`: a timeout or bad reply (never resent: the app may have run the request). + let res = (|| -> Result> { + if let Err(e) = w.write_all(line.as_bytes()) { + return Ok(Err(Error::Bridge(e.to_string()))); + } + let mut buf = String::new(); + match r.read_line(&mut buf) { + Ok(0) => return Ok(Err(Error::Bridge("connection closed by the app".into()))), + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + return Err(Error::Bridge(format!("no reply to `{method}` within {}s (is the app window running?)", READ_TIMEOUT.as_secs()))); + } + Err(e) => return Ok(Err(Error::Bridge(e.to_string()))), + } + serde_json::from_str(&buf).map(Ok).map_err(|e| Error::Bridge(format!("bad reply: {e}"))) + })(); + match res { + Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)), + Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())), + Ok(Err(e)) => { + // Stale connection (app restarted): reconnect and resend once. + self.conn = None; + last = e; + if attempt == 1 { + break; + } + } + Err(e) => { + // The stream may still deliver the late reply: start fresh next call. + self.conn = None; + return Err(e); + } + } + } + Err(last) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 000000000..e3bf88cd1 --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,90 @@ +//! # effectcraft-automation (L5) +//! +//! Makes EffectCraft drivable by AI agents. See `docs/agents.md`. +//! +//! * [`McpServer`]: a Model Context Protocol server (JSON-RPC 2.0, newline-delimited over stdio), +//! hand-rolled on `serde_json`; no async runtime, starts instantly. +//! * [`Backend`]: where tools run. **Headless** owns an in-process [`Session`] (no window); +//! **bridge** forwards to a running desktop app over its JSON-lines control channel +//! (`effectcraft --control 9877`, `docs/control-protocol.md`), adding screenshots and UI input. +//! * [`tools`]: the tool catalogue (names, descriptions, JSON schemas) and their implementations, +//! shared by the MCP server and `effectcraft-cli`'s one-shot subcommands. +//! +//! Layering: depends on `effectcraft-engine` only (never egui); the CLI injects a fully wired +//! session (`effectcraft_host::session()`). + +pub mod backend; +pub mod base64; +pub mod bridge; +pub mod server; +pub mod tools; + +pub use backend::{Backend, Frame}; +pub use bridge::BridgeClient; +pub use effectcraft_engine::Session; +pub use server::McpServer; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Missing or malformed tool arguments. + #[error("{0}")] + BadArgs(String), + #[error(transparent)] + Engine(#[from] effectcraft_engine::EngineError), + /// Transport problem talking to the desktop app. + #[error("bridge: {0}")] + Bridge(String), + /// The desktop app replied with an error. + #[error("{0}")] + App(String), + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Encode RGBA8 pixels as PNG (fast compression), downscaled so the longest side is at most +/// `max_side` (0 = keep). Fully opaque images are written as RGB to save space. +pub fn encode_png(w: u32, h: u32, rgba: Vec, max_side: u32) -> Result> { + use image::ImageEncoder; + use image::codecs::png::{CompressionType, FilterType, PngEncoder}; + let mut img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| Error::Other("bad image buffer".into()))?; + if max_side > 0 && w.max(h) > max_side { + let s = max_side as f64 / w.max(h) as f64; + let (nw, nh) = (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)); + img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle); + } + let (w, h) = img.dimensions(); + let mut out = Vec::new(); + let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive); + let raw = img.into_raw(); + let r = if raw.chunks_exact(4).all(|p| p[3] == 255) { + let rgb: Vec = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8) + } else { + enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8) + }; + r.map_err(|e| Error::Other(format!("png: {e}")))?; + Ok(out) +} + +/// Decode any PNG and re-encode it compressed and capped to `max_side`. Returns (png, w, h). +pub fn recompress_png(bytes: &[u8], max_side: u32) -> Result<(Vec, u32, u32)> { + let img = image::load_from_memory(bytes).map_err(|e| Error::Other(format!("png: {e}")))?.to_rgba8(); + let (w, h) = img.dimensions(); + let png = encode_png(w, h, img.into_raw(), max_side)?; + let (w, h) = png_size(&png).unwrap_or((w, h)); + Ok((png, w, h)) +} + +/// Width and height from a PNG's IHDR. +pub fn png_size(png: &[u8]) -> Option<(u32, u32)> { + if png.len() < 24 || &png[12..16] != b"IHDR" { + return None; + } + let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]); + Some((be(16), be(20))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs new file mode 100644 index 000000000..2a6ea6876 --- /dev/null +++ b/crates/automation/src/server.rs @@ -0,0 +1,144 @@ +//! MCP server: JSON-RPC 2.0, one message per line (the MCP stdio transport). Implements +//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::tools::{self, Reply}; +use crate::{Backend, Error, base64}; + +/// Protocol revisions we speak, newest first. We answer with the client's if we know it. +pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: list_commands discovers ids and params, execute_command runs them (undoable). Typical flow: execute_command comp.new -> execute_command layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> execute_command effect.apply -> render_frame to look at the result. Inspect with get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control `) screenshot and the ui_* tools show and operate the live window."; + +// JSON-RPC error codes. +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +pub struct McpServer { + backend: Backend, +} + +impl McpServer { + pub fn new(backend: Backend) -> Self { + Self { backend } + } + + pub fn backend(&mut self) -> &mut Backend { + &mut self.backend + } + + /// Handle one decoded message (request, notification or batch); `None` when no reply is due. + pub fn handle(&mut self, msg: &Value) -> Option { + if let Value::Array(batch) = msg { + if batch.is_empty() { + return Some(error(Value::Null, INVALID_REQUEST, "empty batch")); + } + let out: Vec = batch.iter().filter_map(|m| self.handle(m)).collect(); + return (!out.is_empty()).then_some(Value::Array(out)); + } + let Some(method) = msg.get("method").and_then(Value::as_str) else { + // A response from the client (we send no requests) or garbage. + return msg + .get("id") + .filter(|_| msg.get("result").is_none() && msg.get("error").is_none()) + .map(|id| error(id.clone(), INVALID_REQUEST, "missing `method`")); + }; + let Some(id) = msg.get("id").cloned() else { + return None; // notification (notifications/initialized, notifications/cancelled, ...) + }; + let params = msg.get("params").cloned().unwrap_or(json!({})); + Some(match self.request(method, ¶ms) { + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Err((code, m)) => error(id, code, &m), + }) + } + + /// Handle one line of input; `None` when no reply is due. + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(line) { + Ok(msg) => self.handle(&msg)?, + Err(e) => error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")), + }; + Some(reply.to_string()) + } + + /// Serve until EOF on `input`. + pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + for line in input.lines() { + if let Some(reply) = self.handle_line(&line?) { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + Ok(()) + } + + /// Serve on stdin/stdout (the MCP stdio transport). + pub fn serve_stdio(&mut self) -> std::io::Result<()> { + self.serve(std::io::stdin().lock(), std::io::stdout().lock()) + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); + let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]); + let mode = if self.backend.is_bridge() { "bridge" } else { "headless" }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + })) + } + "ping" => Ok(json!({})), + "tools/list" => { + let tools: Vec = tools::available(self.backend.is_bridge()).map(|t| t.descriptor()).collect(); + Ok(json!({"tools": tools})) + } + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + if tools::available(self.backend.is_bridge()).all(|t| t.name != name) { + let hint = if tools::find(name).is_some() { format!(" ({})", crate::backend::NEED_BRIDGE) } else { String::new() }; + return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}"))); + } + let args = params.get("arguments").cloned().unwrap_or(json!({})); + Ok(call_result(tools::run(&mut self.backend, name, &args))) + } + // Advertised as absent, but answer politely for clients that probe anyway. + "resources/list" => Ok(json!({"resources": []})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "prompts/list" => Ok(json!({"prompts": []})), + m => Err((METHOD_NOT_FOUND, format!("method not found: {m}"))), + } + } +} + +/// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react. +pub fn call_result(r: Result) -> Value { + match r { + Ok(Reply::Json(v)) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}), + Ok(Reply::Image { png, info }) => json!({ + "content": [ + {"type": "image", "data": base64::encode(&png), "mimeType": "image/png"}, + {"type": "text", "text": info.to_string()}, + ], + "isError": false, + }), + Err(e) => json!({"content": [{"type": "text", "text": e.to_string()}], "isError": true}), + } +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs new file mode 100644 index 000000000..f175b12f9 --- /dev/null +++ b/crates/automation/src/tests.rs @@ -0,0 +1,265 @@ +//! MCP protocol round-trips, in-process (headless) and against a fake control channel (bridge). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; + +use serde_json::{Value, json}; + +use crate::{Backend, McpServer, Session, base64, png_size}; + +fn server() -> McpServer { + McpServer::new(Backend::headless(Session::default())) +} + +/// Send a request, return its `result` (panics on a JSON-RPC error). +fn rpc(s: &mut McpServer, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply: Value = serde_json::from_str(&s.handle_line(&line).expect("reply")).unwrap(); + assert_eq!(reply["jsonrpc"], "2.0"); + assert_eq!(reply["id"], id); + assert!(reply.get("error").is_none(), "{method}: {reply}"); + reply["result"].clone() +} + +/// Call a tool; returns (content, isError). +fn call(s: &mut McpServer, name: &str, args: Value) -> (Vec, bool) { + let r = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args})); + (r["content"].as_array().unwrap().clone(), r["isError"].as_bool().unwrap()) +} + +/// Call a tool that returns JSON text; panics if it failed. +fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value { + let (c, err) = call(s, name, args.clone()); + assert!(!err, "{name} {args}: {c:?}"); + assert_eq!(c[0]["type"], "text"); + serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap() +} + +#[test] +fn initialize_and_list_tools() { + let mut s = server(); + let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}})); + assert_eq!(r["protocolVersion"], "2025-03-26"); + assert_eq!(r["serverInfo"]["name"], "effectcraft"); + assert!(r["capabilities"]["tools"].is_object()); + // Unknown revision: we answer with our latest. + let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(r["protocolVersion"], crate::server::PROTOCOL_VERSIONS[0]); + // Notifications get no reply. + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none()); + assert_eq!(rpc(&mut s, 3, "ping", json!({})), json!({})); + + let tools = rpc(&mut s, 4, "tools/list", json!({}))["tools"].as_array().unwrap().clone(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for want in [ + "list_commands", + "execute_command", + "get_project", + "get_comp", + "get_layer", + "get_property", + "set_property", + "add_keyframe", + "render_frame", + "open_project", + "save_project", + "undo", + "redo", + ] { + assert!(names.contains(&want), "missing {want}"); + } + assert!(!names.contains(&"screenshot"), "bridge-only tools are hidden headless"); + for t in &tools { + assert!(t["description"].as_str().unwrap().len() > 20); + assert_eq!(t["inputSchema"]["type"], "object"); + } +} + +#[test] +fn protocol_errors() { + let mut s = server(); + let r: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32700); + let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":5,"method":"nope"}"#).unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32601); + let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"screenshot"}}"#).unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32602); + // Batches. + let r: Value = + serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":7,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap()).unwrap(); + assert_eq!(r.as_array().unwrap().len(), 1); + // Tool failures are in-band. + let (c, err) = call(&mut s, "execute_command", json!({"command": "no.such"})); + assert!(err); + assert!(c[0]["text"].as_str().unwrap().contains("unknown command")); + let (_, err) = call(&mut s, "render_frame", json!({})); + assert!(err, "no comp yet"); + // Unknown params are rejected with the accepted keys, not silently ignored. + let (c, err) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"nmae": "X"}})); + assert!(err); + assert!(c[0]["text"].as_str().unwrap().contains("accepted: name, width"), "{c:?}"); +} + +#[test] +fn headless_workflow() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"})); + + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new"})); + assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "layer.newSolid")); + + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main", "width": 320, "height": 180, "duration": 4}})); + let l = + call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": "{\"color\":\"#ff0000\",\"width\":100,\"height\":100}"}))["layer"] + .clone(); + assert!(l.is_u64()); + + let comp = call_json(&mut s, "get_comp", json!({"comp": "Main"})); + assert_eq!(comp["width"], 320); + assert_eq!(comp["layers"][0]["id"], l); + + // Static set + read back. + let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/opacity", "value": 50})); + assert_eq!(p["value"], 50.0); + assert_eq!(call_json(&mut s, "get_property", json!({"layer": "#1", "path": "transform/opacity"}))["value"], 50.0); + + // Keyframes with easing. + let p = call_json( + &mut s, + "add_keyframe", + json!({"layer": l, "path": "transform/position", "keys": [{"time": 0, "value": [0, 90]}, {"time": 2, "value": [320, 90]}], "interpolation": "easyEase"}), + ); + assert_eq!(p["keys"].as_array().unwrap().len(), 2); + assert_eq!(p["keys"][0]["out"], "Bezier"); + let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0})); + assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}"); + + // Expression via set_property. + let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"})); + assert_eq!(p["expression"], "time * 90"); + + // Property tree carries paths; flat mode too. + let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true})); + let props = tree["properties"].as_array().unwrap(); + assert!(props.iter().any(|p| p["path"] == "transform/position" && p["keys"] == 2)); + + // Render: an inline PNG at the requested size. + let (c, err) = call(&mut s, "render_frame", json!({"time": 1.0, "max_side": 160})); + assert!(!err, "{c:?}"); + assert_eq!(c[0]["type"], "image"); + assert_eq!(c[0]["mimeType"], "image/png"); + let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap(); + assert_eq!(png_size(&png), Some((160, 90))); + let info: Value = serde_json::from_str(c[1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["width"], 160); + + // Undo / redo. + let before = call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len(); + let u = call_json(&mut s, "undo", json!({"steps": 2})); + assert_eq!(u["steps"], 2); + assert_eq!(u["undoStack"].as_array().unwrap().len(), before - 2); + call_json(&mut s, "redo", json!({"steps": 2})); + assert_eq!(call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len(), before); + + // Save + reopen. + let dir = std::env::temp_dir().join(format!("ec-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.ecproj").to_string_lossy().into_owned(); + let saved = call_json(&mut s, "save_project", json!({"path": path})); + assert_eq!(saved["dirty"], false); + call_json(&mut s, "open_project", json!({"new": true})); + assert!(call(&mut s, "get_comp", json!({})).1, "empty project has no comp"); + let proj = call_json(&mut s, "open_project", json!({"path": path})); + assert_eq!(proj["items"].as_array().unwrap().len(), 3, "comp + Solids folder + solid: {proj}"); + let p = call_json(&mut s, "get_property", json!({"comp": "Main", "layer": "#1", "path": "transform/position"})); + assert_eq!(p["keys"].as_array().unwrap().len(), 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn stdio_loop() { + let mut s = server(); + let input = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "open_project", "arguments": {"demo": true}}}), + ] + .iter() + .map(|v| v.to_string() + "\n") + .collect::(); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1]["id"], 2); + assert_eq!(lines[1]["result"]["isError"], false); +} + +/// A fake desktop control channel: answers `engine.execute` from a real session, and canned +/// `render.frame` / `ui.screenshot` replies. +fn fake_app() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (stream, _) = l.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut session = Session::default(); + for line in BufReader::new(stream).lines() { + let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); + let p = &msg["params"]; + let reply = match msg["method"].as_str().unwrap() { + "engine.execute" => match session.execute(p["command"].as_str().unwrap(), p["params"].clone()) { + Ok(v) => json!({"ok": true, "result": v}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }, + "render.frame" => { + let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); + json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}}) + } + "ui.screenshot" => { + let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap(); + std::fs::write(p["path"].as_str().unwrap(), png).unwrap(); + json!({"ok": true, "result": {"path": p["path"]}}) + } + "ui.key" => json!({"ok": true, "result": null}), + m => json!({"ok": false, "error": format!("unknown method `{m}`")}), + }; + let mut reply = reply; + reply["id"] = msg["id"].clone(); + writeln!(out, "{reply}").unwrap(); + } + }); + port +} + +#[test] +fn bridge_forwards_to_control_channel() { + let port = fake_app(); + let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap()); + let r = rpc(&mut s, 1, "initialize", json!({})); + assert!(r["serverInfo"]["title"].as_str().unwrap().contains("bridge")); + let names: Vec = + rpc(&mut s, 2, "tools/list", json!({}))["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + assert!(names.iter().any(|n| n == "screenshot") && names.iter().any(|n| n == "ui_click")); + + 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 (c, err) = call(&mut s, "render_frame", json!({})); + assert!(!err, "{c:?}"); + assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 2))); + + let (c, err) = call(&mut s, "screenshot", json!({"max_side": 4})); + assert!(!err, "{c:?}"); + assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 4))); + + assert!(!call(&mut s, "ui_key", json!({"key": "Space"})).1); + let (c, err) = call(&mut s, "control", json!({"method": "ui.nope"})); + assert!(err && c[0]["text"].as_str().unwrap().contains("unknown method")); +} + +#[test] +fn bridge_rejects_non_loopback() { + assert!(Backend::bridge("10.0.0.1:9877").is_err()); + assert!(Backend::bridge("localhost:9877").is_ok()); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs new file mode 100644 index 000000000..205573b65 --- /dev/null +++ b/crates/automation/src/tools.rs @@ -0,0 +1,609 @@ +//! The tool catalogue: names, descriptions, JSON input schemas and implementations. Shared by the +//! MCP server (`tools/list`, `tools/call`) and the CLI's one-shot subcommands, so both behave the +//! same. Every tool maps onto engine commands (`Session::execute`) or control-channel methods. + +use serde_json::{Map, Value, json}; + +use crate::backend::NEED_BRIDGE; +use crate::{Backend, Error, Result, recompress_png}; + +/// A tool's output. +pub enum Reply { + Json(Value), + /// A PNG plus a JSON description of it (comp, time, size, path). + Image { + png: Vec, + info: Value, + }, +} + +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// Only listed (and usable) when bridged to the running app. + pub bridge_only: bool, + schema: fn() -> Value, + run: fn(&mut Backend, &Value) -> Result, +} + +impl ToolDef { + /// The JSON Schema of the tool's arguments. + pub fn input_schema(&self) -> Value { + (self.schema)() + } + /// The MCP `Tool` object. + pub fn descriptor(&self) -> Value { + json!({"name": self.name, "description": self.description, "inputSchema": self.input_schema()}) + } +} + +/// All tools (headless + bridge-only). +pub fn catalogue() -> &'static [ToolDef] { + TOOLS +} + +pub fn find(name: &str) -> Option<&'static ToolDef> { + TOOLS.iter().find(|t| t.name == name) +} + +/// The tools available on a backend (bridge-only tools are hidden in headless mode). +pub fn available(bridge: bool) -> impl Iterator { + TOOLS.iter().filter(move |t| bridge || !t.bridge_only) +} + +/// Run a tool by name. +pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result { + let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?; + if t.bridge_only && !b.is_bridge() { + return Err(Error::BadArgs(NEED_BRIDGE.into())); + } + let empty = json!({}); + (t.run)(b, if args.is_null() { &empty } else { args }) +} + +// ------------------------------------------------------------------ schema fragments + +fn schema(props: Value, required: &[&str]) -> Value { + json!({"type": "object", "properties": props, "required": required}) +} +fn comp_s() -> Value { + json!({"type": ["integer", "string"], "description": "Composition item id or name; omit for the active comp."}) +} +fn layer_s() -> Value { + json!({"type": ["integer", "string"], "description": "Layer id (from get_comp), \"#n\" (1-based index from the top) or layer name."}) +} +fn path_s() -> Value { + json!({"type": "string", "description": "Property path from get_layer: match ids joined by `/`, `#n` for the nth child, or `@uid`. E.g. `transform/position`, `transform/opacity`, `transform/scale`, `transform/rotation`, `effects/#1/blurriness`, `masks/#1/feather`, `@57`."}) +} +fn value_s() -> Value { + json!({"description": "Number for 1-D properties (50), array for vectors ([960,540]), \"#rrggbb\" or [r,g,b,a] (0-1) for colours, bool for checkboxes, string for text/menus."}) +} + +// ------------------------------------------------------------------ helpers + +/// Build a params object from the present, non-null entries. +fn obj(entries: &[(&str, Option<&Value>)]) -> Value { + let mut m = Map::new(); + for (k, v) in entries { + if let Some(v) = v.filter(|v| !v.is_null()) { + m.insert(k.to_string(), v.clone()); + } + } + Value::Object(m) +} + +fn get<'a>(a: &'a Value, k: &str) -> Option<&'a Value> { + a.get(k).filter(|v| !v.is_null()) +} + +fn need<'a>(a: &'a Value, k: &str) -> Result<&'a Value> { + get(a, k).ok_or_else(|| Error::BadArgs(format!("missing `{k}`"))) +} + +fn json_reply(v: Value) -> Result { + Ok(Reply::Json(v)) +} + +/// Accept params given as a JSON string too (some clients stringify nested objects). +fn params_of(v: Option<&Value>) -> Result { + match v { + None => Ok(json!({})), + Some(Value::String(s)) if s.trim().is_empty() => Ok(json!({})), + Some(Value::String(s)) => serde_json::from_str(s).map_err(|e| Error::BadArgs(format!("`params` is not valid JSON: {e}"))), + Some(v) => Ok(v.clone()), + } +} + +// ------------------------------------------------------------------ engine tools + +fn list_commands(b: &mut Backend, a: &Value) -> Result { + let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only"))]))?; + // Drop empty fields to keep the listing compact. + let list: Vec = r + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(|mut c| { + 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("{}"))); + } + c + }) + .collect(); + json_reply(json!(list)) +} + +fn execute_command(b: &mut Backend, a: &Value) -> Result { + let id = get(a, "command").or(get(a, "id")).and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `command` (e.g. `comp.new`)".into()))?; + let params = params_of(get(a, "params"))?; + json_reply(b.exec(id, params)?) +} + +fn get_project(b: &mut Backend, _: &Value) -> Result { + json_reply(b.exec("project.summary", json!({}))?) +} + +fn get_comp(b: &mut Backend, a: &Value) -> Result { + json_reply(b.exec("comp.info", obj(&[("comp", get(a, "comp"))]))?) +} + +/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`. +fn flatten(node: &Value, out: &mut Vec) { + match node.get("children").and_then(Value::as_array) { + Some(ch) => ch.iter().for_each(|c| flatten(c, out)), + None => { + let mut o = json!({"path": node["path"], "type": node["type"], "value": node["value"]}); + if let Some(k) = node.get("keys").and_then(Value::as_array) { + o["keys"] = json!(k.len()); + } + if let Some(e) = node.get("expression") { + o["expression"] = e.clone(); + } + out.push(o); + } + } +} + +fn get_layer(b: &mut Backend, a: &Value) -> Result { + let p = obj(&[("layer", Some(need(a, "layer")?)), ("comp", get(a, "comp")), ("depth", get(a, "depth")), ("time", get(a, "time"))]); + let mut r = b.exec("layer.tree", p)?; + if get(a, "flat").and_then(Value::as_bool) == Some(true) { + let mut props = vec![]; + flatten(&r["properties"], &mut props); + r["properties"] = json!(props); + } + json_reply(r) +} + +fn get_property(b: &mut Backend, a: &Value) -> Result { + let p = obj(&[("layer", Some(need(a, "layer")?)), ("path", Some(need(a, "path")?)), ("comp", get(a, "comp")), ("time", get(a, "time"))]); + json_reply(b.exec("prop.get", p)?) +} + +fn set_property(b: &mut Backend, a: &Value) -> Result { + let (layer, path, comp, time) = (need(a, "layer")?, need(a, "path")?, get(a, "comp"), get(a, "time")); + let (value, expr) = (get(a, "value"), get(a, "expression")); + if value.is_none() && expr.is_none() { + return Err(Error::BadArgs("give `value` and/or `expression`".into())); + } + let base = [("layer", Some(layer)), ("path", Some(path)), ("comp", comp)]; + if let Some(v) = value { + let mut p = obj(&base); + p["value"] = v.clone(); + match time { + Some(t) => { + p["time"] = t.clone(); + b.exec("prop.addKey", p)?; + } + None => { + b.exec("prop.set", p)?; + } + } + } + if let Some(e) = expr { + let mut p = obj(&base); + p["expression"] = e.clone(); + b.exec("prop.setExpression", p)?; + } + json_reply(b.exec("prop.get", obj(&[("layer", Some(layer)), ("path", Some(path)), ("comp", comp), ("time", time)]))?) +} + +fn add_keyframe(b: &mut Backend, a: &Value) -> Result { + let (layer, path, comp) = (need(a, "layer")?, need(a, "path")?, get(a, "comp")); + let keys: Vec = match get(a, "keys") { + Some(Value::Array(k)) => k.clone(), + Some(_) => return Err(Error::BadArgs("`keys` must be an array of {time, value?}".into())), + None => vec![obj(&[("time", Some(need(a, "time")?)), ("value", get(a, "value"))])], + }; + if keys.is_empty() { + return Err(Error::BadArgs("no keys".into())); + } + let base = [("layer", Some(layer)), ("path", Some(path)), ("comp", comp)]; + for k in &keys { + let mut p = obj(&base); + p["time"] = need(k, "time")?.clone(); + if let Some(v) = get(k, "value") { + p["value"] = v.clone(); + } + b.exec("prop.addKey", p)?; + } + let info = b.exec("prop.get", obj(&base))?; + if let Some(interp) = get(a, "interpolation").and_then(Value::as_str) { + // Key selection works on the active comp. + if let Some(c) = comp { + b.exec("comp.open", json!({"comp": c}))?; + } + let sel: Vec = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect(); + b.exec("keys.select", json!({"keys": sel}))?; + match interp.to_ascii_lowercase().as_str() { + "easyease" | "ease" => b.exec("keys.easyEase", json!({}))?, + "easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?, + "easyeaseout" => b.exec("keys.easyEase", json!({"which": "out"}))?, + i @ ("linear" | "bezier" | "hold") => b.exec("keys.interpolation", json!({"interpolation": i}))?, + other => return Err(Error::BadArgs(format!("unknown interpolation `{other}` (linear, bezier, hold, easyEase, easyEaseIn, easyEaseOut)"))), + }; + return json_reply(b.exec("prop.get", obj(&base))?); + } + json_reply(info) +} + +fn render_frame(b: &mut Backend, a: &Value) -> Result { + let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(960) as u32; + let f = b.render(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side)?; + let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); + if let Some(p) = get(a, "path").and_then(Value::as_str) { + std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; + info["path"] = json!(p); + } + if get(a, "inline").and_then(Value::as_bool) == Some(false) { + return json_reply(info); + } + Ok(Reply::Image { png: f.png, info }) +} + +fn open_project(b: &mut Backend, a: &Value) -> Result { + if let Some(p) = get(a, "path") { + b.exec("file.open", json!({"path": p}))?; + } else if get(a, "demo").and_then(Value::as_bool) == Some(true) { + b.exec("file.openDemoProject", json!({}))?; + } else if get(a, "new").and_then(Value::as_bool) == Some(true) { + b.exec("file.newProject", json!({}))?; + } else { + return Err(Error::BadArgs("give `path` (.ecproj), `demo: true` or `new: true`".into())); + } + get_project(b, &json!({})) +} + +fn save_project(b: &mut Backend, a: &Value) -> Result { + match get(a, "path") { + Some(p) => b.exec("file.saveAs", json!({"path": p}))?, + None => b.exec("file.save", json!({}))?, + }; + let s = b.exec("project.summary", json!({}))?; + json_reply(json!({"path": s["path"], "dirty": s["dirty"]})) +} + +fn history(b: &mut Backend, a: &Value, id: &str) -> Result { + let steps = get(a, "steps").and_then(Value::as_u64).unwrap_or(1).max(1); + let mut done = 0; + for _ in 0..steps { + match b.exec(id, json!({})) { + Ok(_) => done += 1, + Err(e) if done == 0 => return Err(e), + Err(_) => break, + } + } + let s = b.exec("project.summary", json!({}))?; + json_reply(json!({"steps": done, "undoStack": s["undo"]})) +} + +fn undo(b: &mut Backend, a: &Value) -> Result { + history(b, a, "edit.undo") +} + +fn redo(b: &mut Backend, a: &Value) -> Result { + history(b, a, "edit.redo") +} + +// ------------------------------------------------------------------ bridge-only tools + +fn screenshot(b: &mut Backend, a: &Value) -> Result { + let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32; + let keep = get(a, "path").and_then(Value::as_str).map(str::to_string); + let path = + keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned()); + b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?; + let bytes = std::fs::read(&path).map_err(|e| Error::Other(format!("screenshot: {e}")))?; + if keep.is_none() { + let _ = std::fs::remove_file(&path); + } + let (png, w, h) = recompress_png(&bytes, max_side)?; + Ok(Reply::Image { png, info: json!({"width": w, "height": h, "path": keep}) }) +} + +fn passthrough(b: &mut Backend, method: &str, a: &Value) -> Result { + json_reply(b.control(method, a.clone())?) +} + +fn ui_inspect(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.inspect", a) +} +fn ui_elements(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.elements", a) +} +fn ui_click(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.click", a) +} +fn ui_drag(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.drag", a) +} +fn ui_key(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.key", a) +} +fn ui_type(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.type", a) +} +fn ui_set(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.set", a) +} +fn control(b: &mut Backend, a: &Value) -> Result { + let m = need(a, "method")?.as_str().ok_or_else(|| Error::BadArgs("`method` must be a string".into()))?; + let p = params_of(get(a, "params"))?; + json_reply(b.control(m, p)?) +} + +// ------------------------------------------------------------------ the catalogue + +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`).", + 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."} + }), + &[], + ) + }, + run: list_commands, + }, + ToolDef { + name: "execute_command", + description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.", + bridge_only: false, + schema: || { + schema( + json!({ + "command": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."}, + "params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true} + }), + &["command"], + ) + }, + run: execute_command, + }, + ToolDef { + name: "get_project", + description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.", + bridge_only: false, + schema: || schema(json!({}), &[]), + run: get_project, + }, + ToolDef { + name: "get_comp", + description: "A composition: id, name, size, frame rate, duration, work area, background and its layers top to bottom (id, index, name, type, in/out/start seconds, blend mode, parent, track matte, switches). Layer ids feed get_layer, get_property/set_property and commands.", + bridge_only: false, + schema: || schema(json!({"comp": comp_s()}), &[]), + run: get_comp, + }, + ToolDef { + name: "get_layer", + description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Comp time in seconds for values (default: current time)."}, + "depth": {"type": "integer", "description": "Group depth to expand (default all)."}, + "flat": {"type": "boolean", "description": "Return a flat list of properties instead of the tree."} + }), + &["layer"], + ) + }, + run: get_layer, + }, + ToolDef { + name: "get_property", + description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression.", + bridge_only: false, + schema: || { + schema( + json!({"layer": layer_s(), "path": path_s(), "comp": comp_s(), "time": {"type": "number", "description": "Comp time in seconds."}}), + &["layer", "path"], + ) + }, + run: get_property, + }, + ToolDef { + name: "set_property", + description: "Set a property. Without `time`: sets the static value (or a key at the current time if already animated). With `time`: adds/replaces a keyframe there, turning the stopwatch on. `expression` sets an After Effects-style JS expression (\"\" removes it). Returns the property as get_property does. Examples: {\"layer\":3,\"path\":\"transform/opacity\",\"value\":50}; {\"layer\":\"Title\",\"path\":\"transform/position\",\"value\":[200,540],\"time\":0}; {\"layer\":3,\"path\":\"transform/rotation\",\"expression\":\"time*90\"}.", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."}, + "expression": {"type": "string", "description": "Expression text; empty string removes the expression."} + }), + &["layer", "path"], + ) + }, + run: set_property, + }, + ToolDef { + name: "add_keyframe", + description: "Add keyframes to a property (turns the stopwatch on): one with `time` (+ optional `value`, default the current value) or many with `keys: [{time, value}]`, then optionally set their `interpolation`: linear, bezier, hold, easyEase, easyEaseIn, easyEaseOut. Example: {\"layer\":1,\"path\":\"transform/position\",\"keys\":[{\"time\":0,\"value\":[0,540]},{\"time\":2,\"value\":[1920,540]}],\"interpolation\":\"easyEase\"}.", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "path": path_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Key time in seconds (layer time)."}, + "value": value_s(), + "keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."}, + "interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]} + }), + &["layer", "path"], + ) + }, + run: add_keyframe, + }, + ToolDef { + name: "render_frame", + description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). `path` also writes the PNG to disk; `inline: false` returns only the metadata. Use it to check your work.", + bridge_only: false, + schema: || { + schema( + json!({ + "comp": comp_s(), + "time": {"type": "number", "description": "Comp time in seconds (default: current time)."}, + "max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."}, + "path": {"type": "string", "description": "Also write the PNG here."}, + "inline": {"type": "boolean", "description": "Include the image in the reply (default true)."} + }), + &[], + ) + }, + run: render_frame, + }, + ToolDef { + name: "open_project", + description: "Open a project: `path` to an .ecproj file, `demo: true` for the built-in demo project, or `new: true` for an empty project. Returns the project overview.", + bridge_only: false, + schema: || { + schema(json!({"path": {"type": "string", "description": "Path of an .ecproj file."}, "demo": {"type": "boolean"}, "new": {"type": "boolean"}}), &[]) + }, + run: open_project, + }, + ToolDef { + name: "save_project", + description: "Save the project to its current file, or to `path` (Save As, .ecproj).", + bridge_only: false, + schema: || schema(json!({"path": {"type": "string", "description": "Destination .ecproj path (Save As)."}}), &[]), + run: save_project, + }, + ToolDef { + name: "undo", + description: "Undo the last `steps` edits (default 1). Returns the remaining undo stack.", + bridge_only: false, + schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]), + run: undo, + }, + ToolDef { + name: "redo", + description: "Redo `steps` undone edits (default 1).", + bridge_only: false, + schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]), + run: redo, + }, + ToolDef { + name: "screenshot", + description: "Screenshot of the live app window, or one panel (`Composition`, `Timeline`, `EffectControls`, `Project`, ...) or element id, as PNG (longest side `max_side`, default 1600). Use it to check what the UI looks like after an action.", + bridge_only: true, + schema: || { + schema( + json!({ + "panel": {"type": "string", "description": "Panel to crop to."}, + "id": {"type": "string", "description": "Element id (from ui_elements) to crop to."}, + "max_side": {"type": "integer"}, + "path": {"type": "string", "description": "Keep the full-size PNG at this path."} + }), + &[], + ) + }, + run: screenshot, + }, + ToolDef { + name: "ui_inspect", + description: "UI state: window size, tool, workspace, panels, viewer and timeline view state, playback, active comp, selection, open dialog, render time.", + bridge_only: true, + schema: || schema(json!({}), &[]), + run: ui_inspect, + }, + ToolDef { + name: "ui_elements", + description: "Interactive elements drawn in the last frame: id, label, rect [x,y,w,h] in points. Stable ids such as `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`, `project.item.`, `viewer.comp`, `timeline.layer..bar`, `timeline.prop..stopwatch`, `timeline.cti`, `effects.item.`. Filter with `prefix`.", + bridge_only: true, + schema: || schema(json!({"prefix": {"type": "string", "description": "Only ids starting with this, e.g. `tools.`, `timeline.layer.`."}}), &[]), + run: ui_elements, + }, + ToolDef { + name: "ui_click", + description: "Click an element by `id` (from ui_elements) or at `x`,`y` (points). `button`: left|right|middle, `count`: 2 for double-click, `modifiers`: {shift, alt, command, ctrl}.", + bridge_only: true, + schema: || { + schema( + json!({ + "id": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, + "button": {"type": "string", "enum": ["left", "right", "middle"]}, + "count": {"type": "integer"}, "modifiers": {"type": "object"} + }), + &[], + ) + }, + run: ui_click, + }, + ToolDef { + name: "ui_drag", + description: "Press-move-release from `from` to `to`; each is {\"id\": element} (add fx/fy 0-1 for a spot inside it) or {\"x\",\"y\"}. Moves layers in the viewer, drags layer bars/keyframes/the CTI (`timeline.cti`), scrubs values, draws shapes with a shape tool.", + bridge_only: true, + schema: || { + schema( + json!({"from": {"type": "object"}, "to": {"type": "object"}, "steps": {"type": "integer"}, "modifiers": {"type": "object"}}), + &["from", "to"], + ) + }, + run: ui_drag, + }, + ToolDef { + name: "ui_key", + description: "Press a key or shortcut: `Space` (play), `F9` (Easy Ease), `Cmd+Z`, `Cmd+N`, `PageDown`, `Escape`, `Enter` (`Cmd` = Ctrl off macOS).", + bridge_only: true, + schema: || schema(json!({"key": {"type": "string"}}), &["key"]), + run: ui_key, + }, + ToolDef { + name: "ui_type", + description: "Type text into the focused field (text layer editing, search boxes, value fields).", + bridge_only: true, + schema: || schema(json!({"text": {"type": "string"}}), &["text"]), + run: ui_type, + }, + ToolDef { + name: "ui_set", + description: "Set UI state directly: tool, workspace, theme (dark|darker|light), focused panel, viewer {zoom, res, pan, grid, rulers, safeMargins, transparencyGrid}, timeline {pps, start, graphEditor, showModes, openLayers, openGroups}, menuBar, home.", + bridge_only: true, + schema: || { + schema( + json!({ + "tool": {"type": "string"}, "workspace": {"type": "string"}, "theme": {"type": "string"}, "focused": {"type": "string"}, + "viewer": {"type": "object"}, "timeline": {"type": "object"}, "menuBar": {"type": "boolean"}, "home": {"type": "boolean"} + }), + &[], + ) + }, + run: ui_set, + }, + ToolDef { + name: "control", + description: "Call any control-channel method (docs/control-protocol.md) with its params, e.g. ui.menu.list, ui.menu.invoke {id}, ui.panel.show {panel}, ui.scroll, ui.viewer.locate {x,y}, ui.timeline.locate {layer,time}, ui.playback {action}, ui.resize.", + bridge_only: true, + schema: || schema(json!({"method": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}), &["method"]), + run: control, + }, +]; diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 07d341340..bbccd5d64 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "New Composition…", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, open?}", + "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, pixelAspect?, open?}", always, new_comp ), @@ -214,7 +214,7 @@ pub fn specs() -> Vec { "Composition Settings…", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?}", + "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?}", has_comp, settings ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a86..d93dd262e 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -81,6 +81,65 @@ pub fn find(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +// ---------- parameter validation (agents) ---------- + +/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or +/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. +pub fn accepted_params(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. + let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); + let push = |cur: &mut String, keys: &mut Vec| { + let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if !k.is_empty() && !keys.contains(&k) { + keys.push(k); + } + cur.clear(); + }; + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + push(&mut cur, &mut keys); + return Some(keys); + } + '}' => depth -= 1, + ',' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = false; + } + '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), + ':' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = true; + } + _ if depth == 0 && !in_value => cur.push(c), + _ => {} + } + } + None +} + +/// Keys every command understands (comp targeting, gesture merging) and accepted aliases. +const ALWAYS_OK: &[&str] = &["comp", "merge"]; +const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")]; + +/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. +pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { + let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; + let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); + let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); + if unknown.is_empty() { + return Ok(()); + } + let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; + Err(bad( + spec.id, + format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), + )) +} + // ---------- enablement ---------- pub fn always(_: &Session) -> std::result::Result<(), String> { @@ -182,6 +241,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { s.time() } -pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> { +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) } diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 43bd88e6d..dc9869870 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p}; -use crate::{EngineError, KeyRef, Result, Session, cmd}; +use crate::{EngineError, KeyRef, Result, Session, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { @@ -57,6 +57,21 @@ fn set(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression. +fn get(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "prop.get")?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let keys: Vec = + pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect(); + Ok(json!({ + "layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(), + "time": t.seconds(), "value": pr.value_at(l.layer_time(t)).to_json(), "animated": !pr.keys.is_empty(), + "keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()), + })) +} + fn toggle_anim(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?; let on = b_p(p, "value"); @@ -404,6 +419,7 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get), cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set), cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key), diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 1804eaaff..f38231501 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -76,33 +76,51 @@ fn comp_info(s: &mut Session, p: &Value) -> Result { })) } -fn group_json(g: &PropGroup, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value { +/// The path segment that addresses child `i` of `g` (see `effectcraft_project::parse_path`): the match +/// id (with `#n` when it is not the first sibling matching it), or `#index` when the match id is not +/// path-safe (effects and other ids containing `.`). +fn segment(g: &PropGroup, i: usize) -> String { + let c = &g.children[i]; + let m = c.match_id(); + if m.is_empty() || m.contains(['/', '.', '#', '@']) || m.parse::().is_ok() { + return format!("#{}", i + 1); + } + let n = g.children[..i].iter().filter(|x| x.match_id() == m || x.name().eq_ignore_ascii_case(m)).count() + 1; + if n == 1 { m.to_string() } else { format!("{m}#{n}") } +} + +fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value { let children: Vec = if depth >= max_depth { vec![] } else { g.children .iter() - .map(|c| match c { - Node::Prop(p) => { - let mut o = json!({"uid": p.uid, "match": p.match_id, "name": p.name, "value": p.value_at(l.layer_time(t)).to_json()}); - if !p.keys.is_empty() { - o["keys"] = json!( - p.keys - .iter() - .map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})) - .collect::>() - ); + .enumerate() + .map(|(i, c)| { + let seg = segment(g, i); + let cpath = if path.is_empty() { seg } else { format!("{path}/{seg}") }; + match c { + Node::Prop(p) => { + let mut o = json!({"path": cpath, "uid": p.uid, "match": p.match_id, "name": p.name, "type": p.value.kind_name(), "value": p.value_at(l.layer_time(t)).to_json()}); + if !p.keys.is_empty() { + o["keys"] = json!( + p.keys + .iter() + .map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})) + .collect::>() + ); + } + if let Some(e) = &p.expr { + o["expression"] = json!(e.text); + } + o } - if let Some(e) = &p.expr { - o["expression"] = json!(e.text); - } - o + Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth), } - Node::Group(g) => group_json(g, l, t, depth + 1, max_depth), }) .collect() }; - let mut o = json!({"uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children}); + let mut o = json!({"path": path, "uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children}); match &g.kind { GroupKind::Mask { mode, inverted, .. } => { o["mask"] = json!({"mode": mode.label(), "inverted": inverted}); @@ -121,8 +139,10 @@ fn layer_tree(s: &mut Session, p: &Value) -> Result { let idx = c.index_of(lid).unwrap_or(0); let l = c.layer(lid).ok_or(EngineError::NoComp)?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(16) as usize; + let t = p.get("time").and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let mut o = layer_json(l, idx); - o["properties"] = group_json(&l.props, l, s.time(), 0, depth); + o["time"] = json!(t.seconds()); + o["properties"] = group_json(&l.props, "", l, t, 0, depth); Ok(o) } @@ -135,7 +155,7 @@ pub fn specs() -> Vec { query!("command.list", "List Commands", "{filter?, enabledOnly?}", list), query!("project.summary", "Project Summary", "{}", summary), query!("comp.info", "Composition Info", "{comp?}", comp_info), - query!("layer.tree", "Layer Property Tree", "{layer?, depth?}", layer_tree), + query!("layer.tree", "Layer Property Tree", "{layer?, comp?, depth?, time? (comp s)} → nodes with `path`", layer_tree), query!("editor.state", "Editor State", "{}", state), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18f..1cdfbbcaf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -181,6 +181,14 @@ impl Session { Ok(r) } + /// [`Session::execute`] for agents and scripts (MCP, CLI, control channel): unknown top-level + /// parameters are rejected with the accepted keys instead of being silently ignored. + pub fn execute_checked(&mut self, id: &str, params: Value) -> Result { + let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + commands::check_params(spec, ¶ms)?; + self.execute(id, params) + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } @@ -306,6 +314,25 @@ impl Session { r.comp_frame(comp, t) } + /// Resolve a comp reference (item id number or name; `None`/null = the active comp). + pub fn resolve_comp(&self, comp: Option<&Value>) -> Result { + let p = match comp { + Some(v) if !v.is_null() => serde_json::json!({ "comp": v }), + _ => serde_json::json!({}), + }; + commands::comp_id(self, &p) + } + + /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled + /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba). + pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec)> { + let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let img = self.render(comp, t, RenderOpts { scale, ..Default::default() }); + Ok((img.width, img.height, img.to_rgba8_over(c.background))) + } + /// Replace the whole project (open/new), resetting history and state. pub fn replace_project(&mut self, mut p: Project, path: Option) { p.fix_next_id(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52f..587190f9e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -114,3 +114,76 @@ fn time_navigation() { s.execute("time.set", json!({"frame": 45})).unwrap(); assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50); } + +#[test] +fn prop_get_and_render_rgba8() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [0, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 1.0, "value": [100, 50]})).unwrap(); + let v = s.execute("prop.get", json!({"layer": l, "path": "transform/position", "time": 1.0})).unwrap(); + assert_eq!(v["animated"], true); + assert_eq!(v["keys"].as_array().unwrap().len(), 2); + assert_eq!(v["value"][0].as_f64().unwrap(), 100.0); + let cid = s.resolve_comp(Some(&json!("T"))).unwrap(); + let (w, h, rgba) = s.render_rgba8(cid, effectcraft_time::Tick::ZERO, 160).unwrap(); + assert_eq!((w, h), (160, 90)); + assert_eq!(rgba.len(), (w * h * 4) as usize); +} + +#[test] +fn params_docs_parse() { + use crate::commands::accepted_params; + let k = |d: &str| accepted_params(d).unwrap(); + assert_eq!(k("{layers: [id|name|#n], add?, toggle?}"), ["layers", "add", "toggle"]); + assert_eq!(k("{time? (s) | frame? | timecode?}"), ["time", "frame", "timecode"]); + assert_eq!(k("{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}"), ["interpolation", "in", "out", "autoBezier"]); + assert_eq!(k("{keys: [{layer, prop, time}], add?}"), ["keys", "add"]); + assert_eq!(k("{label: Red|Yellow|Aqua|…, layers?}"), ["label", "layers"]); + assert_eq!(k("{name?, background? [r,g,b]|#hex, open?}"), ["name", "background", "open"]); + assert!(k("{}").is_empty()); + assert!(accepted_params("free text").is_none()); + // Every registered command documents its params as a parseable key list. + for c in crate::command_specs() { + assert!(accepted_params(c.params).is_some(), "{}: {}", c.id, c.params); + } +} + +#[test] +fn execute_checked_rejects_unknown_params() { + let mut s = demo(); + let e = s.execute_checked("layer.select", json!({"index": 2})).unwrap_err().to_string(); + assert!(e.contains("`index`") && e.contains("layers, add, toggle"), "{e}"); + s.execute_checked("layer.select", json!({"layers": ["#2"]})).unwrap(); + assert_eq!(s.state.selected_layers.len(), 1); + // Aliases and always-accepted keys. + s.execute_checked("layer.select", json!({"layer": "#1", "comp": s.active_comp_id().unwrap().0})).unwrap(); + s.execute_checked("time.set", json!({"frame": 3})).unwrap(); + assert!(s.execute_checked("edit.undo", json!({"steps": 2})).is_err()); + // Non-object params are not validated; the unchecked path stays lenient. + s.execute("layer.select", json!({"layers": ["#1"], "index": 2})).unwrap(); +} + +#[test] +fn layer_tree_paths_resolve() { + let mut s = demo(); + s.execute("effect.apply", json!({"layer": 1, "effect": "Gaussian Blur"})).unwrap(); + let comp = s.execute("comp.info", json!({})).unwrap(); + let mut checked = 0; + for l in comp["layers"].as_array().unwrap() { + let tree = s.execute("layer.tree", json!({"layer": l["id"]})).unwrap(); + let mut stack = vec![tree["properties"].clone()]; + while let Some(n) = stack.pop() { + if let Some(ch) = n["children"].as_array() { + stack.extend(ch.iter().cloned()); + continue; + } + let path = n["path"].as_str().unwrap(); + let got = s.execute("prop.get", json!({"layer": l["id"], "path": path})).unwrap(); + assert_eq!(got["uid"], n["uid"], "path {path} on layer {}", l["name"]); + checked += 1; + } + } + assert!(checked > 20, "{checked}"); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f542..492187689 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -16,11 +16,12 @@ impl Importer for MediaImporter { /// A new session with media, import and expressions enabled. pub fn session() -> Session { - let mut s = Session::default(); - s.footage = Arc::new(effectcraft_media::MediaPool::new()); - s.importer = Some(Arc::new(MediaImporter)); - s.expr = Some(Arc::new(effectcraft_expr::Expressions)); - s + Session { + footage: Arc::new(effectcraft_media::MediaPool::new()), + importer: Some(Arc::new(MediaImporter)), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + ..Session::default() + } } #[cfg(test)] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index eaf6ba936..5aa3c9a87 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,7 +16,10 @@ //! - `ui.timeline.locate {layer, time?}`: screen point of a layer bar (at a comp time) //! - `ui.playback {action: play|stop|toggle}` //! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element) +//! - `render.frame {comp?, time?, max_side?, path?, base64?}`: PNG of a comp frame rendered by the session //! - `ui.resize {width, height}` / `ui.focus` / `app.quit` +//! +//! The full reference with examples is `docs/control-protocol.md`. use std::sync::mpsc::Sender; @@ -86,13 +89,24 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; - let params = p.get("params").cloned().unwrap_or(json!({})); - match crate::menus::invoke(app, ctx, id, params) { + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + // `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g. + // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, + // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. + let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { + app.session.execute_checked(id, params).map_err(|e| e.to_string()) + } else { + crate::menus::invoke(app, ctx, id, params) + }; + match r { Ok(v) => ok(v), - Err(e) => err(e), + Err(e) => { + app.ui.status = e.clone(); + err(e) + } } } - "engine.commands" => match app.session.execute("command.list", json!({})) { + "engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) { Ok(v) => ok(v), Err(e) => err(e), }, @@ -301,6 +315,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), crop } } + "render.frame" => render_frame(app, p), "ui.resize" => { let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32; let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32; @@ -320,6 +335,61 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques } } +/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session +/// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a +/// temp file) or inline as base64 when `base64: true`. +fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { + let s = &app.session; + let cid = match s.resolve_comp(p.get("comp")) { + Ok(c) => c, + Err(e) => return err(e), + }; + let t = p.get("time").or(p.get("seconds")).and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let max_side = p.get("max_side").or(p.get("maxSide")).and_then(Value::as_u64).unwrap_or(0) as u32; + let (w, h, rgba) = match s.render_rgba8(cid, t, max_side) { + Ok(r) => r, + Err(e) => return err(e), + }; + let png = match encode_png(&rgba, w, h) { + Ok(p) => p, + Err(e) => return err(e), + }; + let mut out = json!({"comp": cid.0, "time": t.seconds(), "width": w, "height": h}); + if p.get("base64").and_then(Value::as_bool) == Some(true) { + out["png"] = json!(base64(&png)); + return ok(out); + } + let path = p + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-frame-{}.png", std::process::id())).to_string_lossy().to_string()); + match std::fs::write(&path, png) { + Ok(()) => { + out["path"] = json!(path); + ok(out) + } + Err(e) => err(format!("cannot write {path}: {e}")), + } +} + +/// Standard base64 (RFC 4648, padded). +pub fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for i in 0..4 { + if i <= c.len() { + s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + s.push('='); + } + } + } + s +} + pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); json!({ @@ -417,3 +487,13 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { chunk(&mut out, b"IEND", &[]); Ok(out) } + +#[cfg(test)] +mod tests { + #[test] + fn base64_rfc4648_vectors() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { + assert_eq!(super::base64(i.as_bytes()), o); + } + } +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 000000000..ea4abf69b --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,103 @@ +# Driving EffectCraft from agents + +Everything in EffectCraft is an engine command with a stable id and JSON params. Menus, shortcuts, +panel gestures, the CLI, the control channel and MCP all go through the same commands, so anything a +user can do, an agent can do too. There are three ways in: + +| Interface | Best for | Needs a window | +|---|---|---| +| **MCP** (`effectcraft-cli mcp`) | Claude Code and other MCP clients | no (headless), or yes with `--bridge` | +| **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no | +| **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes | + +## MCP setup + +Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude +Code, add a `.mcp.json` at the project root: + +```json +{ + "mcpServers": { + "effectcraft": { + "command": "/path/to/effectcraft/target/release/effectcraft-cli", + "args": ["mcp"] + } + } +} +``` + +You can also register it from the command line: +`claude mcp add effectcraft -- /path/to/target/release/effectcraft-cli mcp`. Other clients (Claude +Desktop, Cursor and the like) take the same `command` and `args`. + +- **Headless** (`["mcp"]`): an in-process session with no window. Add `"--demo"` or + `"--project", "file.ecproj"` to start with content. Startup is instant. +- **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you + see every change live. Bridge mode adds `screenshot` and the `ui_*` tools. + +The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MCP protocol versions +2025-06-18, 2025-03-26 and 2024-11-05 (`initialize`, `ping`, `tools/list`, `tools/call`). + +### Tools + +| Tool | What it does | +|---|---| +| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. | +| `execute_command {command, params?}` | Run any command (undoable). | +| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | +| `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. | +| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | +| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | +| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | +| `render_frame {comp?, time?, max_side?, path?, inline?}` | Returns a PNG image of a frame. | +| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | +| `undo {steps?}` / `redo {steps?}` | History. | +| *(bridge)* `screenshot {panel?, id?}`, `ui_inspect`, `ui_elements {prefix?}`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_set`, `control {method, params}` | Look at and operate the live window. | + +Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are +referenced by id or name, and default to the active comp. Property paths come from `get_layer`, for +example `transform/position`, `transform/opacity`, `effects/#1/blurriness` or `@57` (by uid). Times +are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or +stretched. + +Command params are checked against each command's `params` doc. An unknown key, such as +`layer.select {"index": 2}`, returns an error that lists the accepted keys (here `layers, add, toggle`) +instead of being ignored. + +### A typical session + +1. `execute_command {"command":"comp.new","params":{"name":"Intro","width":1920,"height":1080,"frameRate":30,"duration":4}}` +2. `execute_command {"command":"layer.newText","params":{"text":"Hello","size":160,"fill":"#ffffff"}}` returns `{"layer": 2}`. +3. `add_keyframe {"layer":2,"path":"transform/position","keys":[{"time":0,"value":[-300,540]},{"time":1.5,"value":[960,540]}],"interpolation":"easyEase"}` +4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}` +5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it. + +## CLI + +Each invocation runs a headless engine with no window. It opens the demo project unless you pass +`--project F.ecproj`, a positional `*.ecproj` or `--empty`. Add `--json` for one compact JSON document +on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors exit with status 2. + +```sh +effectcraft-cli info --json +effectcraft-cli commands --filter keys +effectcraft-cli exec comp.new --params '{"name":"Main","width":1280,"height":720}' --empty --save-as main.ecproj +effectcraft-cli exec layer.newSolid '{"color":"#3366ff"}' main.ecproj --save +effectcraft-cli props Main '#1' --project main.ecproj # flat property list with paths +effectcraft-cli set Main '#1' transform/opacity 40 main.ecproj --save +effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save +effectcraft-cli get Main '#1' transform/position --time 0.5 main.ecproj --json +effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json +effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json +effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app +``` + +`` is an id or name, or `-` for the active comp. `` is JSON (`50`, `[960,540]`, +`"#ff0000"`) or a bare string. + +## Seeing the UI + +To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` and use MCP bridge +mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag` +to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the +composition itself at any zoom. diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 000000000..2f2f25d1f --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,87 @@ +# Control protocol + +The desktop app exposes a control channel for agents, tests and the MCP bridge: + +```sh +effectcraft --control 9877 # or EFFECTCRAFT_CONTROL_PORT=9877 +``` + +- **Transport:** TCP on `127.0.0.1` only, one JSON object per line in each direction. Connections are + persistent; requests on one connection are answered in order. +- **Request:** `{"id": 1, "method": "engine.execute", "params": {...}}` (`id` is echoed back, `params` + defaults to `{}`). +- **Reply:** `{"id": 1, "ok": true, "result": ...}` or `{"id": 1, "ok": false, "error": "message"}`. +- Requests run on the UI thread between frames. Input methods (`ui.click`, `ui.key` and similar) + reply after the synthetic input has been processed. Methods that need an element that isn't drawn + yet are retried for a few frames. A request times out after 60 s. + +Quick test: + +```sh +printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params":{"name":"A"}}}\n' | nc 127.0.0.1 9877 +``` + +## Engine + +| Method | Params | Result | +|---|---|---| +| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. | +| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). | +| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` | +| `ui.menu.list` | `{}` | The menu tree with enablement. | +| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: ""}` and writes no file. | + +Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`, +`comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`), +`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?}`. + +## UI state + +| Method | Params | Result | +|---|---|---| +| `ui.inspect` | `{}` | Window size, pixels per point, fps, serialized `UiState`, playback, time, active comp, selection, open dialog, last render ms. | +| `ui.elements` | `{prefix?}` | `[{id, label, rect: [x, y, w, h]}]`: the widgets drawn in the last frame, in points. | +| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. | +| `ui.panel.show` / `ui.panel.close` | `{panel}` | Panels: `Project`, `Composition`, `Timeline`, `EffectControls`, `EffectsPresets`, `Info`, `Preview`, `Character`, `Paragraph`, `Align` and others. | +| `ui.playback` | `{action: play\|stop\|toggle}` | `{playing, time}` | +| `ui.resize` | `{width, height}` | Resizes the window (points). | +| `ui.focus` | `{}` | Activates the window and takes keyboard focus. Agents normally avoid this, because the app is started without stealing focus. | +| `app.quit` | `{}` | Closes the app. | + +## Input + +Points are logical window coordinates. A target is `{id}` (the element's centre; add `fx`/`fy` between +0 and 1 to pick a spot inside it) or `{x, y}`. `modifiers` is `{shift, alt, command, ctrl}`, where +`command` is ⌘ on macOS and Ctrl elsewhere. + +| Method | Params | +|---|---| +| `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | +| `ui.move` | `{id \| x,y}` | +| `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | +| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | +| `ui.type` | `{text}`: text input into the focused field | + +## Locating things + +| Method | Params | Result | +|---|---|---| +| `ui.viewer.locate` | `{x, y}` (comp pixels) | `{x, y}` window point | +| `ui.viewer.hit` | `{id \| x,y}` (window point) | `{x, y}` comp pixel | +| `ui.timeline.locate` | `{layer (id), time?}` | `{x, y}` window point on the layer bar | + +## Screenshots + +| Method | Params | Result | +|---|---|---| +| `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. | + +Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`, +`header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, +`timeline.layer..bar`, `timeline.layer..twirl`, `timeline.prop..stopwatch`, +`timeline.key..`, `timeline.cti`, `effectControls.prop..value` and +`effects.item.`. Use `ui.elements` to see what is on screen. + +The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol; +see [agents.md](agents.md). From c91af194f2661670719bd8073987521e0828760d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:43:48 -0400 Subject: [PATCH 07/32] M9.3: effect env (masks, layer params, audio, comp timing), sim cache, polyline helpers; module stubs Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 3 +- crates/effects/src/channel.rs | 3 +- crates/effects/src/channel2.rs | 7 + crates/effects/src/color2.rs | 3 +- crates/effects/src/color3.rs | 7 + crates/effects/src/distort2.rs | 2 +- crates/effects/src/distort3.rs | 7 + crates/effects/src/generate2.rs | 2 +- crates/effects/src/generate3.rs | 7 + crates/effects/src/keying.rs | 3 +- crates/effects/src/keying2.rs | 7 + crates/effects/src/lib.rs | 112 ++++++++++++- crates/effects/src/matte.rs | 3 +- crates/effects/src/noise.rs | 2 +- crates/effects/src/noise2.rs | 7 + crates/effects/src/obsolete.rs | 7 + crates/effects/src/perspective.rs | 3 +- crates/effects/src/perspective2.rs | 7 + crates/effects/src/sim.rs | 7 + crates/effects/src/sim2.rs | 7 + crates/effects/src/stylize2.rs | 2 +- crates/effects/src/stylize3.rs | 7 + crates/effects/src/textfx.rs | 7 + crates/effects/src/transition.rs | 3 +- crates/effects/src/transition2.rs | 7 + crates/effects/src/util.rs | 243 +++++++++++++++++++++++++++++ crates/effects/src/utility.rs | 3 +- crates/effects/src/utility2.rs | 7 + crates/media/src/pool.rs | 7 + crates/render/src/lib.rs | 44 +++++- crates/render/src/masks.rs | 28 ++++ 31 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 crates/effects/src/channel2.rs create mode 100644 crates/effects/src/color3.rs create mode 100644 crates/effects/src/distort3.rs create mode 100644 crates/effects/src/generate3.rs create mode 100644 crates/effects/src/keying2.rs create mode 100644 crates/effects/src/noise2.rs create mode 100644 crates/effects/src/obsolete.rs create mode 100644 crates/effects/src/perspective2.rs create mode 100644 crates/effects/src/sim.rs create mode 100644 crates/effects/src/sim2.rs create mode 100644 crates/effects/src/stylize3.rs create mode 100644 crates/effects/src/textfx.rs create mode 100644 crates/effects/src/transition2.rs create mode 100644 crates/effects/src/utility2.rs diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 95853e7ca..def9d2071 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -621,7 +621,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index 161fde37e..383a95af0 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -425,7 +425,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs new file mode 100644 index 000000000..70516adc5 --- /dev/null +++ b/crates/effects/src/channel2.rs @@ -0,0 +1,7 @@ +//! Channel effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 136ba00e4..8d707c685 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -886,7 +886,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs new file mode 100644 index 000000000..cc39ceb90 --- /dev/null +++ b/crates/effects/src/color3.rs @@ -0,0 +1,7 @@ +//! Color Correction effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 20eb1bdc0..c3496c595 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -690,7 +690,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs new file mode 100644 index 000000000..4dca9381e --- /dev/null +++ b/crates/effects/src/distort3.rs @@ -0,0 +1,7 @@ +//! Distort effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 784f19168..6aa306826 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -754,7 +754,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs new file mode 100644 index 000000000..e50cf9f56 --- /dev/null +++ b/crates/effects/src/generate3.rs @@ -0,0 +1,7 @@ +//! Generate effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 106a9d2f6..c451c7510 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -617,7 +617,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs new file mode 100644 index 000000000..5b514cb9c --- /dev/null +++ b/crates/effects/src/keying2.rs @@ -0,0 +1,7 @@ +//! Keying effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa25..0355bd781 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,22 +8,36 @@ mod blur2; mod channel; +mod channel2; mod color2; +mod color3; mod color_fx; mod controls; mod distort; mod distort2; +mod distort3; mod generate; mod generate2; +mod generate3; mod keying; +mod keying2; mod matte; mod misc; mod noise; +mod noise2; +mod obsolete; mod perspective; +mod perspective2; +mod sim; +mod sim2; mod stylize2; +mod stylize3; +mod textfx; mod transition; +mod transition2; pub mod util; mod utility; +mod utility2; use std::collections::HashMap; use std::sync::OnceLock; @@ -122,6 +136,45 @@ impl Buf { } } +/// A layer mask at the current time, flattened to a polyline in layer coordinates. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MaskShape { + pub name: String, + pub points: Vec<[f64; 2]>, + pub closed: bool, + pub inverted: bool, +} + +/// Pixels of another layer referenced by a layer parameter: its buffer (layer space, see +/// [`Buf`]) and its source size in layer pixels. +#[derive(Clone, Debug, Default)] +pub struct LayerPixels { + pub buf: Buf, + pub size: [f64; 2], +} + +/// Services the renderer offers effects that look beyond their own pixels. +pub trait EffectHost: Sync { + /// Layer `id` of the same composition at the current comp time. `masks_and_effects` + /// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references. + fn layer(&self, id: u64, masks_and_effects: bool) -> Option; + /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp + /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. + fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option>; +} + +/// Extra context the renderer may supply (all optional; `Default` is "nothing known"). +#[derive(Clone, Copy, Default)] +pub struct EffectEnv<'a> { + /// The layer's masks (enabled ones, top to bottom). + pub masks: &'a [MaskShape], + pub host: Option<&'a dyn EffectHost>, + /// Composition time in seconds. + pub comp_time: f64, + /// Composition frame rate (0 when unknown). + pub frame_rate: f64, +} + /// What an effect gets to render with. pub struct EffectCtx<'a> { pub params: &'a Params, @@ -133,6 +186,20 @@ pub struct EffectCtx<'a> { pub seed: u32, /// Rendering for an adjustment layer (operating on the comp below). pub adjustment: bool, + /// Masks, other layers, audio, comp timing. + pub env: EffectEnv<'a>, +} + +impl EffectCtx<'_> { + /// The layer chosen in layer parameter `id`, via the host. + pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option { + let lid = self.params.get(id)?.as_layer()?; + self.env.host?.layer(lid, masks_and_effects) + } + /// Frame rate for frame-based maths (comp rate, else 30). + pub fn fps(&self) -> f64 { + if self.env.frame_rate > 0.0 { self.env.frame_rate } else { 30.0 } + } } pub type RenderFn = fn(&EffectCtx, Buf) -> Buf; @@ -187,6 +254,20 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(perspective::specs()); v.extend(transition::specs()); v.extend(utility::specs()); + v.extend(sim::specs()); + v.extend(sim2::specs()); + v.extend(distort3::specs()); + v.extend(perspective2::specs()); + v.extend(utility2::specs()); + v.extend(generate3::specs()); + v.extend(textfx::specs()); + v.extend(stylize3::specs()); + v.extend(transition2::specs()); + v.extend(channel2::specs()); + v.extend(keying2::specs()); + v.extend(noise2::specs()); + v.extend(color3::specs()); + v.extend(obsolete::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); v }) @@ -227,10 +308,39 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) } +/// Test helper: run effect `id` on `img` (scale 1, layer size = image size) with its defaults +/// overridden by `vals`, at layer time `time`, with an optional environment. +#[cfg(test)] +pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = find(id).unwrap_or_else(|| panic!("no effect {id}")); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + assert!(params.values.contains_key(*k), "{id}: unknown param {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env }; + apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn run_fx_passthrough() { + let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); + let out = run_fx("ec.control.slider", &[("slider", num(3.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, img.data); + } + + #[test] + #[ignore] + fn dump_registry() { + for s in registry() { + println!("{} | {} | {}", s.category, s.name, s.id); + } + } + #[test] fn registry_is_consistent() { let r = registry(); @@ -259,7 +369,7 @@ mod tests { } for s in registry() { let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false, env: Default::default() }; let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }); assert!(!out.img.is_empty(), "{}", s.id); assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id); diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 05dcd4514..49914c801 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -200,7 +200,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 759309e68..8809c1e6f 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -531,7 +531,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs new file mode 100644 index 000000000..bb1953bcc --- /dev/null +++ b/crates/effects/src/noise2.rs @@ -0,0 +1,7 @@ +//! Noise & Grain effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs new file mode 100644 index 000000000..331686c1a --- /dev/null +++ b/crates/effects/src/obsolete.rs @@ -0,0 +1,7 @@ +//! Obsolete-category effects. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 2a032eca8..014353fc8 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -452,7 +452,8 @@ mod tests { params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64])); } } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs new file mode 100644 index 000000000..60c61d027 --- /dev/null +++ b/crates/effects/src/perspective2.rs @@ -0,0 +1,7 @@ +//! Perspective effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs new file mode 100644 index 000000000..dc8d20edd --- /dev/null +++ b/crates/effects/src/sim.rs @@ -0,0 +1,7 @@ +//! Simulation effects (particles, physics, liquids). + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs new file mode 100644 index 000000000..d8d6ce96a --- /dev/null +++ b/crates/effects/src/sim2.rs @@ -0,0 +1,7 @@ +//! Simulation effects, part 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 5eee2b4fd..52e0b5c81 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -698,7 +698,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs new file mode 100644 index 000000000..16a4399ef --- /dev/null +++ b/crates/effects/src/stylize3.rs @@ -0,0 +1,7 @@ +//! Stylize effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs new file mode 100644 index 000000000..ad66f9573 --- /dev/null +++ b/crates/effects/src/textfx.rs @@ -0,0 +1,7 @@ +//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index b1ea60745..49e12cd5a 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -486,7 +486,8 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs new file mode 100644 index 000000000..5f33c4b38 --- /dev/null +++ b/crates/effects/src/transition2.rs @@ -0,0 +1,7 @@ +//! Transition effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 346d7619a..9249ecf92 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -427,10 +427,253 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64 (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) } +/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other +/// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. +pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { + let ls = ctx.layer_size; + let os = other.size; + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + gen_image(b.img.width, b.img.height, |x, y| { + let px = (x as f64 + 0.5 - b.offset[0]) * inv; + let py = (y as f64 + 0.5 - b.offset[1]) * inv; + let qx = px * sx + dx; + let qy = py * sy + dy; + other.buf.img.sample_bilinear(qx * other.buf.scale + other.buf.offset[0], qy * other.buf.scale + other.buf.offset[1]) + }) +} + +/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of +/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour). +pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image { + match ctx.layer_param(id, masks_and_effects) { + Some(o) => fit_layer(ctx, b, &o, stretch), + None => b.img.clone(), + } +} + +/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer +/// scale (cache key for simulations). +pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut keys: Vec<&String> = ctx.params.values.keys().collect(); + keys.sort(); + for k in keys { + k.hash(&mut h); + format!("{:?}", ctx.params.values[k]).hash(&mut h); + } + ctx.seed.hash(&mut h); + ctx.layer_size[0].to_bits().hash(&mut h); + ctx.layer_size[1].to_bits().hash(&mut h); + b.scale.to_bits().hash(&mut h); + salt.hash(&mut h); + h.finish() +} + +/// Checkpointed, deterministic, time-seekable simulation cache. +/// +/// A simulation is a state advanced in fixed steps from layer time 0. [`SimCache::run`] returns +/// the state after `target` steps, starting from the nearest cached checkpoint at or before it +/// (so scrubbing forward is incremental and seeking anywhere gives the same answer as +/// simulating from scratch). Entries are keyed by [`params_key`]; the least recently used key +/// is evicted beyond `cap` keys. +pub struct SimCache { + inner: std::sync::Mutex>>, + cap: usize, +} + +struct SimEntry { + key: u64, + used: u64, + checkpoints: Vec<(u64, std::sync::Arc)>, +} + +/// Checkpoint every this many steps. +pub const SIM_CHECKPOINT: u64 = 15; + +impl SimCache { + pub const fn new(cap: usize) -> SimCache { + SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } + } + + /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step + /// `i` to `i + 1`. + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + use std::sync::Arc; + let start = { + let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; + g.iter_mut().find(|e| e.key == key).and_then(|e| { + e.used = tick; + e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + }) + }; + let had_start = start.is_some(); + let (mut i, mut st) = match start { + Some((s, st)) if s == target => return st, + Some((s, st)) => (s, (*st).clone()), + None => (0, init()), + }; + let mut fresh: Vec<(u64, Arc)> = Vec::new(); + if !had_start { + fresh.push((0, Arc::new(st.clone()))); + } + while i < target { + step(&mut st, i); + i += 1; + if i % SIM_CHECKPOINT == 0 && i != target { + fresh.push((i, Arc::new(st.clone()))); + } + } + let out = Arc::new(st); + fresh.push((target, out.clone())); + let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; + let idx = match g.iter().position(|e| e.key == key) { + Some(ix) => ix, + None => { + if g.len() >= self.cap.max(1) { + let old = g.iter().enumerate().min_by_key(|(_, e)| e.used).map(|(ix, _)| ix).unwrap_or(0); + g.remove(old); + } + g.push(SimEntry { key, used: tick, checkpoints: Vec::new() }); + g.len() - 1 + } + }; + let e = &mut g[idx]; + e.used = tick; + for (s, st) in fresh { + if !e.checkpoints.iter().any(|(c, _)| *c == s) { + e.checkpoints.push((s, st)); + } + } + // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. + if e.checkpoints.len() > 256 { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + e.checkpoints.sort_by_key(|(s, _)| *s); + let mut k = 0; + e.checkpoints.retain(|_| { + k += 1; + k % 2 == 1 + }); + } + out + } +} + +/// Point-in-polygon (even-odd) for a closed polyline. +pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool { + let n = pts.len(); + if n < 3 { + return false; + } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (a, b) = (pts[i], pts[j]); + if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] { + inside = !inside; + } + j = i; + } + inside +} + +/// Distance from a point to a polyline (closed adds the last→first segment). +pub fn dist_to_poly(pts: &[[f64; 2]], closed: bool, x: f64, y: f64) -> f64 { + let n = pts.len(); + if n == 0 { + return f64::INFINITY; + } + if n == 1 { + return ((pts[0][0] - x).powi(2) + (pts[0][1] - y).powi(2)).sqrt(); + } + let segs = if closed { n } else { n - 1 }; + let mut best = f64::INFINITY; + for i in 0..segs { + let a = pts[i]; + let b = pts[(i + 1) % n]; + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (px, py) = (a[0] + dx * t, a[1] + dy * t); + best = best.min((px - x).powi(2) + (py - y).powi(2)); + } + best.sqrt() +} + +/// Total length of a polyline. +pub fn poly_length(pts: &[[f64; 2]], closed: bool) -> f64 { + let n = pts.len(); + if n < 2 { + return 0.0; + } + let segs = if closed { n } else { n - 1 }; + (0..segs) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % n]); + ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt() + }) + .sum() +} + +/// Point and unit tangent at arc length `s` along a polyline. +pub fn poly_point_at(pts: &[[f64; 2]], closed: bool, s: f64) -> ([f64; 2], [f64; 2]) { + let n = pts.len(); + if n == 0 { + return ([0.0; 2], [1.0, 0.0]); + } + if n == 1 { + return (pts[0], [1.0, 0.0]); + } + let segs = if closed { n } else { n - 1 }; + let mut rem = s.max(0.0); + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + if rem <= l || i == segs - 1 { + let t = if l > 0.0 { (rem / l).min(1.0) } else { 0.0 }; + let d = if l > 0.0 { [(b[0] - a[0]) / l, (b[1] - a[1]) / l] } else { [1.0, 0.0] }; + return ([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], d); + } + rem -= l; + } + (pts[n - 1], [1.0, 0.0]) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn sim_cache_is_seek_consistent() { + static C: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let direct = *C.run(1, 100, || 7, step); + let fresh: SimCache = SimCache::new(2); + let a = *fresh.run(1, 40, || 7, step); + let b = *fresh.run(1, 100, || 7, step); + let c = *fresh.run(1, 40, || 7, step); + let d = *fresh.run(1, 77, || 7, step); + let e = *C.run(1, 77, || 7, step); + assert_eq!(b, direct); + assert_eq!(a, c); + assert_eq!(d, e); + } + + #[test] + fn poly_helpers() { + let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!(point_in_poly(&sq, 5.0, 5.0)); + assert!(!point_in_poly(&sq, 15.0, 5.0)); + assert!((poly_length(&sq, true) - 40.0).abs() < 1e-9); + assert!((dist_to_poly(&sq, true, 5.0, 5.0) - 5.0).abs() < 1e-9); + let (pt, _) = poly_point_at(&sq, true, 15.0); + assert!((pt[0] - 10.0).abs() < 1e-9 && (pt[1] - 5.0).abs() < 1e-9); + } + #[test] fn minmax_matches_brute_force() { let src: Vec = (0..37).map(|i| ((i * 7919) % 31) as f32).collect(); diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 2c3eebd0c..49b7ed364 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -363,7 +363,8 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/utility2.rs b/crates/effects/src/utility2.rs new file mode 100644 index 000000000..fcd43649a --- /dev/null +++ b/crates/effects/src/utility2.rs @@ -0,0 +1,7 @@ +//! Utility effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 37787442a..773f78e99 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,6 +454,13 @@ impl FootageSource for MediaPool { } } } + + fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + if !footage.has_audio || footage.missing { + return None; + } + Some(self.audio_samples(footage, t, frames, rate)) + } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd3356..eeca6aae4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,7 +14,7 @@ pub mod text; use std::sync::Arc; use effectcraft_color::BlendMode; -use effectcraft_effects::{Buf, EffectCtx, Params}; +use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; use effectcraft_geom::{Mat3, vec2}; use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling}; pub use effectcraft_raster::Image; @@ -27,6 +27,43 @@ use rayon::prelude::*; pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option>; + /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at + /// `rate` Hz (`None` when unavailable). + fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { + None + } +} + +/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). +struct FxHost<'r, 'a, 'c> { + r: &'r Renderer<'a>, + ctx: &'c EvalCtx<'a>, + layer: &'c Layer, +} + +impl EffectHost for FxHost<'_, '_, '_> { + fn layer(&self, id: u64, masks_and_effects: bool) -> Option { + let other = self.ctx.layer(effectcraft_project::LayerId(id))?; + if other.id == self.layer.id || self.r.depth > 8 { + return None; + } + let sub = Renderer { project: self.r.project, footage: self.r.footage, expr: self.r.expr, opts: self.r.opts, depth: self.r.depth + 1 }; + let buf = if masks_and_effects { sub.layer_buf(self.ctx, other)? } else { sub.source(self.ctx, other)? }; + let size = source_size(self.r.project, other); + let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + Some(LayerPixels { buf, size }) + } + + fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { + let other = self.ctx.layer(effectcraft_project::LayerId(id))?; + let LayerSource::Footage { item } = &other.source else { return None }; + let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + if !f.has_audio { + return None; + } + let st = other.layer_time(Tick::from_seconds_f64(start)); + self.r.footage.audio(*item, f, st, frames, rate) + } } /// No footage available (renders footage layers as transparent). @@ -134,6 +171,9 @@ impl<'a> Renderer<'a> { let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + let mask_shapes = masks::shapes(ctx, layer); + let host = FxHost { r: self, ctx, layer }; + let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() }; for g in fx.groups() { if !g.enabled { continue; @@ -141,7 +181,7 @@ impl<'a> Renderer<'a> { let GroupKind::Effect { effect } = &g.kind else { continue }; let Some(spec) = effectcraft_effects::find(effect) else { continue }; let params = self.effect_params(ctx, layer, g); - let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment }; + let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; buf = effectcraft_effects::apply(spec, &ectx, buf); } buf diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 30eea2b38..f2805065d 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -39,6 +39,34 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu Some(cov) } +/// The layer's enabled masks at the context time, flattened to polylines in layer space (for +/// effects that use masks as paths: Stroke, Scribble, Inner/Outer Key, Reshape…). +pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec { + let Some(masks) = layer.masks() else { return Vec::new() }; + let mut out = Vec::new(); + for g in masks.groups().filter(|g| g.enabled) { + let GroupKind::Mask { inverted, .. } = g.kind else { continue }; + let Some(Value::Path(sp)) = ctx.group_value(layer, g, "path") else { continue }; + let path = effectcraft_path::to_kurbo(&sp); + let mut pts: Vec<[f64; 2]> = Vec::new(); + let mut closed = false; + kurbo::flatten(path.iter(), 0.25, |el| match el { + effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => { + if pts.last() != Some(&[p.x, p.y]) { + pts.push([p.x, p.y]); + } + } + effectcraft_path::PathEl::ClosePath => closed = true, + _ => {} + }); + if closed && pts.len() > 1 && pts.first() == pts.last() { + pts.pop(); + } + out.push(effectcraft_effects::MaskShape { name: g.name.clone(), points: pts, closed: closed || sp.closed, inverted }); + } + out +} + /// Apply the layer's masks to its buffer. pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) { let Some(masks) = layer.masks() else { return }; From a99be1499f9909c767593b1c8207779c835c0451 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:45:01 -0400 Subject: [PATCH 08/32] =?UTF-8?q?M0.9:=20Effect=20Controls=20parity=20?= =?UTF-8?q?=E2=80=94=20sliders=20behind=20param=20twirls=20with=20range=20?= =?UTF-8?q?labels,=20About...=20header,=20no=20hex;=20headless=20snapshot?= =?UTF-8?q?=20feeds=20synthetic=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 15 ++- crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/panels/effect_controls.rs | 98 ++++++++++++++------ crates/ui-egui/src/state.rs | 4 + 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 6cc9b175e..e98acbdaf 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -60,14 +60,23 @@ fn parse_args() -> Result { Ok(a) } +/// One frame, first queueing the app's synthetic input (kittest doesn't call +/// `raw_input_hook`); the harness runs one frame per queued event. +fn step_frame(h: &mut Harness<'_, EffectcraftApp>) { + for e in h.state_mut().take_synthetic_input() { + h.event(e); + } + h.step(); +} + /// Step the app for `secs` of wall time so background frame renders land in the viewer. fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) { let end = Instant::now() + Duration::from_secs_f64(secs); while Instant::now() < end { - h.step(); + step_frame(h); std::thread::sleep(Duration::from_millis(30)); } - h.step(); + step_frame(h); } fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> { @@ -110,7 +119,7 @@ fn main() { // Requests are answered on a later frame (some wait for input to be processed). let deadline = Instant::now() + Duration::from_secs(10); let reply = loop { - harness.step(); + step_frame(&mut harness); if let Ok(v) = reply_rx.try_recv() { break v; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 821a946d0..c17190861 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -517,6 +517,14 @@ impl EffectcraftApp { } } +impl EffectcraftApp { + /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call + /// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves. + pub fn take_synthetic_input(&mut self) -> Vec { + std::mem::take(&mut self.synthetic) + } +} + impl eframe::App for EffectcraftApp { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { if !self.synthetic.is_empty() { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0397c552d..f173adeb3 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -89,24 +89,18 @@ fn prop_row( if let Some(nv) = nv { set(actions, json!(nv)); } - // Slider track for slider params. - if let ParamUi::Slider { slider_min, slider_max, .. } = prop.ui { - let tr = Rect::from_min_max(pos2(vr.max.x + 10.0, cy - 2.0), pos2(r.max.x - 12.0, cy + 2.0)); - if tr.width() > 40.0 { - p.rect_filled(tr, 2.0, t.field_bg); - let f = ((v - slider_min) / (slider_max - slider_min)).clamp(0.0, 1.0) as f32; - p.rect_filled(Rect::from_min_max(tr.min, pos2(tr.min.x + tr.width() * f, tr.max.y)), 2.0, t.accent.gamma_multiply(0.7)); - let knob = pos2(tr.min.x + tr.width() * f, cy); - p.circle_filled(knob, 5.0, t.text); - let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag()); - app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name); - if (sresp.dragged() || sresp.clicked()) - && let Some(pt) = sresp.interact_pointer_pos() - { - let f = ((pt.x - tr.min.x) / tr.width()).clamp(0.0, 1.0) as f64; - set(actions, json!(slider_min + (slider_max - slider_min) * f)); + // AE hides a slider param's slider until its twirl is opened (see `slider_row`). + if matches!(prop.ui, ParamUi::Slider { .. }) { + let open = app.ui.fx_slider_open.contains(&uid); + let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); + if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() { + if open { + app.ui.fx_slider_open.remove(&uid); + } else { + app.ui.fx_slider_open.insert(uid); } } + app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); } } Value::Vec2(_) | Value::Vec3(_) => { @@ -139,13 +133,6 @@ fn prop_row( if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } - p.text( - pos2(sr.max.x + 8.0, cy), - Align2::LEFT_CENTER, - effectcraft_engine::color::Rgba::from_array([c[0] as f32, c[1] as f32, c[2] as f32, 1.0]).to_hex(), - Tokens::mono(11.0), - t.text_dim, - ); } Value::Bool(b) => { let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); @@ -187,6 +174,48 @@ fn prop_row( } } +/// The twirled-open slider under a slider param: track, knob and the slider range's ends. +#[allow(clippy::too_many_arguments)] +fn slider_row( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + prop: &Property, + ectx: &EvalCtx, + r: Rect, + x0: f32, + actions: &mut Actions, +) { + let t = app.tokens; + let ParamUi::Slider { slider_min, slider_max, decimals, .. } = prop.ui else { return }; + let Value::Scalar(v) = ectx.value(layer, prop) else { return }; + let uid = prop.uid; + let tr = Rect::from_min_max(pos2(r.min.x + x0, r.min.y + 8.0), pos2(r.max.x - 16.0, r.min.y + 12.0)); + if tr.width() < 40.0 { + return; + } + p.rect_filled(tr, 2.0, t.field_bg); + let f = ((v - slider_min) / (slider_max - slider_min)).clamp(0.0, 1.0) as f32; + p.rect_filled(Rect::from_min_max(tr.min, pos2(tr.min.x + tr.width() * f, tr.max.y)), 2.0, t.accent.gamma_multiply(0.7)); + p.circle_filled(pos2(tr.min.x + tr.width() * f, tr.center().y), 5.0, t.text); + let dec = decimals as usize; + p.text(pos2(tr.min.x, tr.max.y + 4.0), Align2::LEFT_TOP, format!("{slider_min:.dec$}"), Tokens::ui(10.5), t.text_dim); + p.text(pos2(tr.max.x, tr.max.y + 4.0), Align2::RIGHT_TOP, format!("{slider_max:.dec$}"), Tokens::ui(10.5), t.text_dim); + let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag()); + app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name); + if (sresp.dragged() || sresp.clicked()) + && let Some(pt) = sresp.interact_pointer_pos() + { + let f = ((pt.x - tr.min.x) / tr.width()).clamp(0.0, 1.0) as f64; + actions.push(( + "prop.set".into(), + json!({"layer": layer.id.0, "prop": uid, "value": slider_min + (slider_max - slider_min) * f, "merge": format!("ec-{uid}")}), + )); + } +} + +#[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, ui: &mut egui::Ui, @@ -212,6 +241,11 @@ fn group_rows( continue; } prop_row(app, ui, p, layer, pr, ectx, r, 24.0 + 14.0 * depth as f32, actions); + if app.ui.fx_slider_open.contains(&pr.uid) { + let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0)); + *y += 34.0; + slider_row(app, ui, p, layer, pr, ectx, sr, 24.0 + 14.0 * depth as f32 + 22.0, actions); + } } Node::Group(sg) => { *y += 24.0; @@ -291,7 +325,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); - let reset = Rect::from_min_size(pos2(r.max.x - 92.0, r.min.y + 4.0), vec2(40.0, 18.0)); + let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click()); bp.text(reset.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim }); app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset"); @@ -309,11 +343,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - let rm = Rect::from_min_size(pos2(r.max.x - 28.0, r.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::icon_button(ui, rm, Icon::Close, false, &t, egui::Id::new(("ec-rm", g.uid))).on_hover_text("Remove effect").clicked() { - actions.push(("effect.remove".into(), json!({"layer": layer.id.0, "effect": g.uid}))); + // About…: the effect's name and category (AE's About dialog). + let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0)); + let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click()); + bp.text(about.center(), Align2::CENTER_CENTER, "About...", Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); + let apop = egui::Id::new(("ec-about-pop", g.uid)); + if aresp.clicked() { + widgets::open_popup(ui, apop); + } + if let Some(spec) = g.kind_effect().and_then(effectcraft_engine::effects::find) { + let lines = vec![spec.name.to_string(), format!("Category: {}", spec.category), "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string()]; + let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); } - app.auto.add(&format!("effectControls.effect.{}.remove", g.uid), rm, "Remove"); let hresp = ui.interact(Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), Sense::click()); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3662b7f9d..ba092f979 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -298,6 +298,9 @@ pub struct UiState { pub project_sort_desc: bool, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, + /// Slider params whose slider row is twirled open (AE hides sliders by default). + #[serde(default)] + pub fx_slider_open: BTreeSet, /// Shape tool options. pub fill_color: [f32; 3], pub stroke_color: [f32; 3], @@ -331,6 +334,7 @@ impl Default for UiState { project_sort: default_project_sort(), project_sort_desc: false, fx_closed: BTreeSet::new(), + fx_slider_open: BTreeSet::new(), fill_color: [0.24, 0.55, 0.96], stroke_color: [1.0, 1.0, 1.0], stroke_width: 0.0, From 951ed1fa3d468d209bfa663779202eb03e3ce81f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:47:29 -0400 Subject: [PATCH 09/32] =?UTF-8?q?M5.2=E2=80=93M5.5:=20keyframe=20clipboard?= =?UTF-8?q?,=20easy=20ease,=20velocity/interpolation=20dialogs,=20roving,?= =?UTF-8?q?=20time=20remap/stretch/reverse/freeze,=20graph=20editor,=20sep?= =?UTF-8?q?arate=20dimensions,=20pick-whip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine (all commands with undo/redo + tests in engine/src/tests_timeline.rs): - keys.copy/paste (Edit Copy/Cut/Paste route to keys when keys are selected; relative timing kept, paste at CTI, cross-property paste of same type), keys.selectAll (Cmd+Alt+A), keys.nudge* (Alt+Arrow, Alt+Shift+Arrow), keys.set / keys.setEase (graph editor drags), keys.info (dialog data), keys.timeReverse; keys.interpolation gains continuous/auto Bezier, spatial interpolation and roving; keys.velocity takes per-dimension values and Continuous. - Roving keys (keyframe::retime_roving): constant speed along the motion path. - Layer > Time: layer.timeStretch (percent|duration, hold in/current/out), layer.timeReverse (Cmd+Alt+R), layer.enableTimeRemap (Cmd+Alt+T), layer.freezeFrame, layer.freezeOnLastFrame; render uses Time Remap as the source time for precomp/footage layers. - prop.separateDimensions (X/Y/Z Position; prop.set on Position writes them). - layer.setParent compensates so the layer stays in place (compensate:false to opt out). - prop.pickWhip writes AE-style reference expressions (thisComp.layer(..), effect(..)(..), mask(..).maskFeather, temp=…;[temp,temp], …[0]). - prop.setExpression: syntax errors keep the text but disable the expression. - expr: arrays of properties returned by an expression are resolved. UI: Keyframe Velocity, Keyframe Interpolation and Time Stretch dialogs; Graph Editor (value + speed graphs, draggable keys and Bezier/influence handles, auto-zoom, fit, show selected, separate dimensions, hold/linear/ auto Bezier/Easy Ease buttons); key + layer context menus. Clears the workspace clippy -D warnings failures. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 3 +- apps/effectcraft/src/main.rs | 8 +- crates/engine/src/commands/anim.rs | 414 +++++++++++++++++++++ crates/engine/src/commands/edit.rs | 24 +- crates/engine/src/commands/layer.rs | 81 ++-- crates/engine/src/commands/layer_time.rs | 186 ++++++++++ crates/engine/src/commands/link.rs | 182 +++++++++ crates/engine/src/commands/mod.rs | 8 +- crates/engine/src/commands/prop.rs | 203 ++++++++-- crates/engine/src/lib.rs | 34 ++ crates/engine/src/tests_timeline.rs | 454 +++++++++++++++++++++++ crates/expr/src/prelude.js | 3 + crates/host/src/lib.rs | 69 +++- crates/keyframe/src/lib.rs | 148 ++++++++ crates/keyframe/src/value.rs | 1 + crates/project/src/props.rs | 32 ++ crates/render/src/eval.rs | 27 +- crates/render/src/lib.rs | 6 +- crates/render/src/tests_remap.rs | 65 ++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 12 +- crates/ui-egui/src/panels/graph.rs | 452 ++++++++++++++++++++++ crates/ui-egui/src/panels/key_dialogs.rs | 287 ++++++++++++++ crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/project.rs | 6 +- crates/ui-egui/src/panels/timeline.rs | 150 +++----- crates/ui-egui/src/panels/viewer.rs | 18 +- crates/ui-egui/src/state.rs | 27 ++ 29 files changed, 2705 insertions(+), 203 deletions(-) create mode 100644 crates/engine/src/commands/anim.rs create mode 100644 crates/engine/src/commands/layer_time.rs create mode 100644 crates/engine/src/commands/link.rs create mode 100644 crates/engine/src/tests_timeline.rs create mode 100644 crates/render/src/tests_remap.rs create mode 100644 crates/ui-egui/src/panels/graph.rs create mode 100644 crates/ui-egui/src/panels/key_dialogs.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58f..b83076746 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -22,7 +22,8 @@ fn open(s: &mut Session, args: &[String]) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // `--demo` or no project: the demo project. let _ = s.execute("file.openDemoProject", json!({})); } } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 6ee51c56d..c0894e8cb 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -54,10 +54,10 @@ fn main() -> eframe::Result { let _ = session.execute("file.openDemoProject", json!({})); } let media: Vec = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect(); - if !media.is_empty() { - if let Err(e) = session.execute("file.import", json!({"paths": media})) { - eprintln!("effectcraft: {e}"); - } + if !media.is_empty() + && let Err(e) = session.execute("file.import", json!({"paths": media})) + { + eprintln!("effectcraft: {e}"); } let mut app = EffectcraftApp::new(session); app.ui.start_screen = home; diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs new file mode 100644 index 000000000..f3560ccdd --- /dev/null +++ b/crates/engine/src/commands/anim.rs @@ -0,0 +1,414 @@ +//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key +//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. + +use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; +use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::prop::{edit_keys, prop_ref}; +use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p}; +use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query}; + +fn has_key_clip(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.key_clipboard.is_empty() { Err("no keyframes have been copied".into()) } else { Ok(()) } +} + +/// Copy the selected keyframes (Edit ▸ Copy with keys selected). +fn copy(s: &mut Session, _: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &s.state.selected_keys { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + // Earliest selected key in comp time: pasted keys keep their offsets from it. + let mut earliest: Option = None; + for ((lid, _), times) in &groups { + if let Some(l) = comp.layer(*lid) { + for t in times { + let ct = l.comp_time(*t); + earliest = Some(earliest.map_or(ct, |e: Tick| e.min(ct))); + } + } + } + let earliest = earliest.ok_or_else(|| bad("keys.copy", "select keyframes first"))?; + let mut clip = vec![]; + for ((lid, uid), times) in groups { + let Some(l) = comp.layer(lid) else { continue }; + let Some(pr) = l.props.find(uid) else { continue }; + let Some(path) = l.props.match_path_of(uid) else { continue }; + let mut keys: Vec = pr.keys.iter().filter(|k| times.contains(&k.time)).cloned().collect(); + for k in &mut keys { + k.time = l.comp_time(k.time) - earliest; + } + clip.push(KeyClip { layer: lid, path, keys }); + } + let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.key_clipboard = clip; + s.state.clip_is_keys = true; + Ok(json!(n)) +} + +/// Values of the same kind (and dimensionality) can receive each other's keys. +fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool { + a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len() +} + +/// Paste copied keyframes at the current time into the target layers' properties. +fn paste(s: &mut Session, p: &Value) -> Result { + let clip = s.state.key_clipboard.clone(); + if clip.is_empty() { + return Err(bad("keys.paste", "no keyframes have been copied")); + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let (_, mut targets) = layers_p(s, p)?; + if targets.is_empty() { + targets = clip.iter().map(|c| c.layer).collect(); + targets.dedup(); + } + let explicit = p.get("prop").and_then(Value::as_u64).or_else(|| { + let path = str_p(p, "path")?; + comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid) + }); + // (layer, prop uid, clip index) + let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; + for lid in &targets { + let Some(l) = comp.layer(*lid) else { continue }; + for (ci, c) in clip.iter().enumerate() { + let Some(sample) = c.keys.first().map(|k| &k.value) else { continue }; + let target = if clip.len() == 1 { + explicit + .and_then(|u| l.props.find(u)) + .or_else(|| { + s.state.selected_props.iter().filter(|(sl, _)| sl == lid).filter_map(|(_, u)| l.props.find(*u)).find(|pr| compatible(pr, sample)) + }) + .or_else(|| l.props.prop(&c.path)) + } else { + l.props.prop(&c.path) + }; + if let Some(pr) = target.filter(|pr| compatible(pr, sample) && !pr.static_only) { + plan.push((*lid, pr.uid, ci)); + } + } + } + if plan.is_empty() { + return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); + } + s.edit("Paste Keyframes", None, |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut sel = vec![]; + for (lid, uid, ci) in &plan { + let Some(l) = comp.layer_mut(*lid) else { continue }; + let times: Vec = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect(); + let Some(pr) = l.props.find_mut(*uid) else { continue }; + for (k, lt) in clip[*ci].keys.iter().zip(times) { + let mut k = k.clone(); + k.time = lt; + if let Some(v) = pr.value.coerce_json(&k.value.to_json()) { + k.value = v; + } + if pr.hold_only { + k = k.hold(); + } + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(KeyRef { layer: *lid, prop: *uid, time: lt }); + } + retime_roving(&mut pr.keys, pr.spatial); + } + st.selected_keys = sel; + Ok(json!(st.selected_keys.len())) + }) +} + +/// Select every keyframe of the selected properties, or of the given / selected layers. +fn select_all(s: &mut Session, p: &Value) -> Result { + let (cid, mut layers) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let mut sel = vec![]; + if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() { + for (lid, uid) in &s.state.selected_props { + if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) { + sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + } else { + if layers.is_empty() { + layers = comp.layers.iter().map(|l| l.id).collect(); + } + for l in comp.layers.iter().filter(|l| layers.contains(&l.id)) { + l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time }))); + } + } + s.state.selected_keys = sel; + Ok(json!(s.state.selected_keys.len())) +} + +/// Move the selected keys by whole frames (Alt+→ / Alt+←, Shift for 10 frames). +fn nudge(s: &mut Session, p: &Value) -> Result { + let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?; + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let d = Tick(comp.frame_duration().0 * frames); + edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| { + *t += d; + true + }) +} + +/// Edit one key directly: move it in time and/or set its value (Graph Editor drags). +fn set_key(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.set")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?); + let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64); + let value = p.get("value").cloned(); + let fr = s.project.comp(cid).map(|c| c.frame_rate); + s.edit("Edit Keyframe", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?; + let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?; + let old_t = pr.keys[i].time; + if let Some(v) = &value { + let nv = pr.keys[i].value.coerce_json(v).ok_or_else(|| bad("keys.set", format!("can't use {v} here")))?; + pr.keys[i].value = nv; + } + let mut nt = old_t; + if let Some(n) = new_t { + nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n); + // Keys can't pass their neighbours or share a time. + let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN }; + let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX }; + nt = Tick(nt.0.clamp(lo, hi)); + pr.keys[i].time = nt; + } + retime_roving(&mut pr.keys, pr.spatial); + let nt = pr.keys.get(i).map(|k| k.time).unwrap_or(nt); + for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) { + k.time = nt; + } + Ok(json!({"time": nt.seconds()})) + }) +} + +fn nearest_key(keys: &[Keyframe], t: Tick) -> Option { + if let Some(i) = key_at(keys, t) { + return Some(i); + } + keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i) +} + +/// Set one side's temporal ease of a key (Graph Editor handle drags, scripting). +fn set_ease(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.setEase")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setEase", "missing `time`"))?); + let out = match str_p(p, "side") { + Some("in") => false, + Some("out") => true, + _ => return Err(bad("keys.setEase", "`side` must be in or out")), + }; + let dim = p.get("dim").and_then(Value::as_u64).map(|d| d as usize); + let speed = f_p(p, "speed"); + let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0)); + s.edit("Keyframe Velocity", merge_p(p), |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?; + let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?; + let spatial = pr.spatial; + let n = if spatial && matches!(pr.keys[i].value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_)) { + 1 + } else { + pr.keys[i].value.dims().max(1) + }; + // Start from what the side presents now so untouched dimensions keep their shape. + let cur: Vec = (0..n).map(|d| side_ease(&pr.keys, i, d, spatial, out).unwrap_or_default()).collect(); + let k = &mut pr.keys[i]; + let list = if out { &mut k.out_ease } else { &mut k.in_ease }; + if list.len() != n { + *list = cur.clone(); + } + for (d, e) in list.iter_mut().enumerate() { + if dim.is_some_and(|x| x != d) && n > 1 { + continue; + } + if let Some(v) = speed { + e.speed = v; + } + if let Some(v) = influence { + e.influence = v; + } + } + if out { + k.out_interp = Interp::Bezier; + } else { + k.in_interp = Interp::Bezier; + } + k.auto_bezier = false; + // Continuous Bezier keys keep matching speeds on both sides. + if k.continuous { + let src = if out { k.out_ease.clone() } else { k.in_ease.clone() }; + let other = if out { &mut k.in_ease } else { &mut k.out_ease }; + if other.len() != src.len() { + *other = src.clone(); + } + for (o, sv) in other.iter_mut().zip(&src) { + o.speed = sv.speed; + } + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + Ok(Value::Null) + }) +} + +/// Speed units for a property (Keyframe Velocity dialog). +fn units(pr: &Property) -> &'static str { + use effectcraft_project::ParamUi; + if pr.spatial { + return "pixels/sec"; + } + match pr.ui { + ParamUi::Angle => "degrees/sec", + ParamUi::Percent => "%/sec", + ParamUi::Slider { .. } if pr.match_id == "opacity" => "%/sec", + _ if pr.match_id == "opacity" => "%/sec", + ParamUi::Pixels => "pixels/sec", + _ => "units/sec", + } +} + +fn interp_name(i: Interp) -> &'static str { + match i { + Interp::Linear => "linear", + Interp::Bezier => "bezier", + Interp::Hold => "hold", + } +} + +/// Describe the selected (or given) keys: interpolation, eases per side and dimension, units. +fn info(s: &mut Session, p: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let refs: Vec = match p.get("keys") { + Some(Value::Array(a)) => a + .iter() + .filter_map(|k| { + let l = super::resolve_layer(comp, k.get("layer")?)?; + Some(KeyRef { layer: l, prop: k.get("prop")?.as_u64()?, time: Tick::from_seconds_f64(k.get("time")?.as_f64()?) }) + }) + .collect(), + _ => s.state.selected_keys.clone(), + }; + let mut out = vec![]; + for r in refs { + let Some(l) = comp.layer(r.layer) else { continue }; + let Some(pr) = l.props.find(r.prop) else { continue }; + let Some(i) = nearest_key(&pr.keys, r.time) else { continue }; + let k = &pr.keys[i]; + let spatial = pr.spatial && matches!(k.value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_)); + let dims = if spatial { 1 } else { k.value.dims().max(1).min(if pr.shown_dims > 0 && !l.is_3d() { pr.shown_dims as usize } else { 4 }) }; + let side = |out: bool| -> Value { + json!( + (0..dims) + .map(|d| side_ease(&pr.keys, i, d, pr.spatial, out) + .map(|e| json!({"speed": e.speed, "influence": e.influence * 100.0})) + .unwrap_or(Value::Null)) + .collect::>() + ) + }; + let temporal = if k.auto_bezier { + "autoBezier" + } else if k.continuous { + "continuousBezier" + } else if k.in_interp == k.out_interp { + interp_name(k.in_interp) + } else { + "mixed" + }; + let spatial_kind = if !spatial { + Value::Null + } else if k.spatial_auto { + json!("autoBezier") + } else if k.spatial_continuous { + json!("continuousBezier") + } else if k.spatial_in == [0.0; 3] && k.spatial_out == [0.0; 3] { + json!("linear") + } else { + json!("bezier") + }; + out.push(json!({ + "layer": l.id.0, "prop": pr.uid, "name": pr.name, "path": l.props.match_path_of(pr.uid), + "time": k.time.seconds(), "compTime": l.comp_time(k.time).seconds(), "value": k.value.to_json(), + "in": interp_name(k.in_interp), "out": interp_name(k.out_interp), "temporal": temporal, + "autoBezier": k.auto_bezier, "continuous": k.continuous, "roving": k.roving, "spatial": spatial_kind, + "dims": dims, "units": units(pr), "inEase": side(false), "outEase": side(true), + })); + } + Ok(json!(out)) +} + +/// Keyframe Assistant ▸ Time-Reverse Keyframes: mirror the selected keys of each property. +fn time_reverse(s: &mut Session, _: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let sel = s.state.selected_keys.clone(); + s.edit("Time-Reverse Keyframes", None, |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &sel { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + let mut new_sel = vec![]; + for ((lid, uid), times) in groups { + let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; + let (mut picked, rest): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); + effectcraft_keyframe::time_reverse(&mut picked); + new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); + pr.keys = rest; + for k in picked { + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + } + } + st.selected_keys = new_sel; + Ok(json!(st.selected_keys.len())) + }) +} + +fn keys_or_layers(s: &Session) -> std::result::Result<(), String> { + has_comp(s) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy), + cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste), + cmd!("keys.selectAll", "Select All Keyframes", ["Edit"], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all), + cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge), + cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))), + cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))), + cmd!("keys.nudgeForward10", "Move Keyframes 10 Frames Later", [], Some("Alt+Shift+ArrowRight"), "{}", has_keys, |s, _| nudge( + s, + &json!({"frames": 10}) + )), + cmd!("keys.nudgeBackward10", "Move Keyframes 10 Frames Earlier", [], Some("Alt+Shift+ArrowLeft"), "{}", has_keys, |s, _| nudge( + s, + &json!({"frames": -10}) + )), + cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key), + cmd!( + "keys.setEase", + "Set Keyframe Ease", + [], + None, + "{layer?, path|prop, time (layer s), side: in|out, dim?, speed?, influence? %, merge?}", + has_comp, + set_ease + ), + cmd!("keys.timeReverse", "Time-Reverse Keyframes", ["Animation", "Keyframe Assistant"], None, "{}", has_keys, time_reverse), + query!("keys.info", "Keyframe Info", "{keys?: [{layer, prop, time}]}", info), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b68df62de..3fc62cf8e 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -15,7 +15,7 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> { } fn has_clip(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; - if s.state.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) } + if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) } } fn undo(s: &mut Session, _: &Value) -> Result { @@ -97,18 +97,30 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn copy(s: &mut Session, p: &Value) -> Result { + // Keyframes selected → copy keys (pasted at the CTI). + if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { + return s.execute("keys.copy", json!({})); + } let (cid, ids) = layers_p(s, p)?; + s.state.clip_is_keys = false; let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; s.state.clipboard = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); Ok(json!(s.state.clipboard.len())) } fn cut(s: &mut Session, p: &Value) -> Result { + if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { + s.execute("keys.copy", json!({}))?; + return s.execute("keys.delete", json!({})); + } copy(s, p)?; delete(s, p) } fn paste(s: &mut Session, p: &Value) -> Result { + if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { + return s.execute("keys.paste", p.clone()); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { @@ -191,10 +203,10 @@ pub fn specs() -> Vec { vec![ cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?}", has_layers, cut), - cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?}", has_layers, copy), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut), + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clip, paste), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", has_layers, delete), + cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), @@ -204,6 +216,10 @@ pub fn specs() -> Vec { ] } +fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { + if !s.state.selected_keys.is_empty() { super::has_comp(s) } else { has_layers(s) } +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index f329dfaa3..d6b4593e2 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -456,10 +456,62 @@ fn set_parent(s: &mut Session, p: &Value) -> Result { cur = comp.layer(c).and_then(|l| l.parent); } } + // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed + // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. + let compensate = b_p(p, "compensate").unwrap_or(true); + let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() }; + let space = |id: Option| -> effectcraft_geom::Mat3 { + id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) + }; + let decompose = |m: &effectcraft_geom::Mat3| { + let a = m.0; + let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); + let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; + (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) + }; + // Per layer: (id, position transform, rotation delta, scale factors). + let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![]; + if compensate { + let new_m = space(parent); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) { + let old_m = space(l.parent); + let Some(inv) = new_m.inverse() else { continue }; + let (ro, sxo, syo) = decompose(&old_m); + let (rn, sxn, syn) = decompose(&new_m); + let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; + fixes.push((l.id, inv * old_m, ro - rn, k)); + } + } s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.parent = parent; + let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue }; + let Some(tr) = l.props.sub_mut("transform") else { continue }; + let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for key in &mut pr.keys { + key.value = f(&key.value); + } + }; + if tr.get("positionX").is_none() + && let Some(pr) = tr.get_mut("position") + { + map(pr, &|v| { + let c = v.as_vec3(); + let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); + KV::Vec3([q.x, q.y, c[2]]) + }); + } + if let Some(pr) = tr.get_mut("rotation") { + map(pr, &|v| KV::Scalar(v.as_f64() + dr)); + } + if let Some(pr) = tr.get_mut("scale") { + map(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) + }); + } } Ok(()) })?; @@ -790,34 +842,6 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -fn time_stretch(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let reverse = str_p(p, "op") == Some("reverse"); - let pct = f_p(p, "percent"); - s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - let old = l.stretch; - let new = if reverse { -old } else { pct.unwrap_or(old) }; - if new.abs() < 1.0 { - continue; - } - // Keep the in point fixed: the layer's source span scales around it. - let lin = l.layer_time(l.in_point); - let lout = l.layer_time(l.out_point); - l.stretch = new; - if reverse { - l.start_time = l.in_point + Tick((lout.0 as f64 * new.abs() / 100.0) as i64); - } else { - l.start_time = l.in_point - Tick((lin.0 as f64 * new / 100.0) as i64); - l.out_point = l.comp_time(lout); - } - } - Ok(()) - })?; - Ok(Value::Null) -} - fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; let color = color_p(p, "color"); @@ -949,7 +973,6 @@ pub fn specs() -> Vec { has_layers, transform_op ), - cmd!("layer.timeStretch", "Time Stretch…", ["Layer", "Time"], None, "{layers?, percent?, op?: reverse}", has_layers, time_stretch), ] } diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs new file mode 100644 index 000000000..583d03bb5 --- /dev/null +++ b/crates/engine/src/commands/layer_time.rs @@ -0,0 +1,186 @@ +//! Layer ▸ Time: Time Stretch, Time-Reverse Layer, Enable Time Remapping, Freeze Frame and +//! Freeze on Last Frame. +//! +//! Time Remap is a `timeRemap` property at the top of the layer's tree whose value is the +//! source time (seconds) to show; its keys live in layer time like every other property. + +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::build::Ids; +use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Layers whose source has its own time (precomps and footage) can be remapped. +fn remappable(l: &Layer) -> bool { + matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. }) +} + +fn time_stretch(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let reverse = str_p(p, "op") == Some("reverse"); + let pct = f_p(p, "percent"); + let dur = f_p(p, "duration"); + let hold = str_p(p, "hold").unwrap_or("in").to_string(); + let cti = s.time(); + let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration(); + s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + let old = l.stretch; + if reverse { + // Keep the in/out points; play the visible span backwards. + let last = l.layer_time(l.out_point - fd); + l.stretch = -old; + l.start_time = l.in_point - Tick((last.0 as f64 * l.stretch / 100.0).round() as i64); + continue; + } + let span = (l.out_point - l.in_point).seconds(); + let new = match (pct, dur) { + (Some(v), _) => v, + (None, Some(d)) if span > 0.0 => old * d / span, + _ => old, + }; + if new.abs() < 1.0 { + return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); + } + // The hold point keeps showing the same layer time. + let h = match hold.as_str() { + "current" => cti, + "out" => l.out_point, + _ => l.in_point, + }; + let lt_h = l.layer_time(h); + let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); + l.stretch = new; + l.start_time = h - Tick((lt_h.0 as f64 * new / 100.0).round() as i64); + let (a, b) = (l.comp_time(lin), l.comp_time(lout)); + l.in_point = a.min(b); + l.out_point = a.max(b); + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn remap_prop(ids: &mut Ids) -> Property { + let mut p = ids.prop("timeRemap", "Time Remap", KV::Scalar(0.0)).with_ui(ParamUi::Number); + p.ui = ParamUi::Number; + p +} + +/// Source time (seconds) a layer shows at comp time `t` without remapping. +fn source_secs(l: &Layer, t: Tick) -> f64 { + l.layer_time(t).seconds() +} + +fn enable_time_remap(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let want = b_p(p, "value"); + let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) { + return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer")); + } + let on = s.edit("Enable Time Remapping", None, |proj, _| { + let mut next = proj.next_id; + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut on = false; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + let has = l.props.get("timeRemap").is_some(); + let target = want.unwrap_or(!has); + on |= target; + if target && !has { + let mut pr = remap_prop(&mut Ids(&mut next)); + let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); + let (a, b) = (lin.min(lout), lin.max(lout)); + pr.value = KV::Scalar(source_secs(l, l.in_point)); + pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))]; + if lin > lout { + // Reversed layers keep showing what they showed. + pr.keys[0].value = KV::Scalar(source_secs(l, l.out_point - fd)); + pr.keys[1].value = KV::Scalar(source_secs(l, l.in_point)); + } + l.props.children.insert(0, Node::Prop(pr)); + } else if !target && has { + l.props.children.retain(|c| c.match_id() != "timeRemap"); + } + } + proj.next_id = next; + Ok(on) + })?; + Ok(json!(on)) +} + +/// Freeze Frame: time remapping holding the current source frame for the whole layer. +fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { + let (cid, ids) = layers_p(s, p)?; + let cti = s.time(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (fd, cdur) = (comp.frame_duration(), comp.duration); + if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) { + return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); + } + // What each layer shows now (honouring an existing remap) — evaluated before the edit. + let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() }; + let now: Vec<(effectcraft_project::LayerId, f64)> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id) && remappable(l)) + .map(|l| { + let at = if last { ectx.at(l.out_point - fd) } else { ectx }; + (l.id, at.source_time(l).seconds()) + }) + .collect(); + s.edit(if last { "Freeze on Last Frame" } else { "Freeze Frame" }, None, |proj, _| { + let mut next = proj.next_id; + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (lid, src) in &now { + let Some(l) = comp.layer_mut(*lid) else { continue }; + if l.props.get("timeRemap").is_none() { + l.props.children.insert(0, Node::Prop(remap_prop(&mut Ids(&mut next)))); + } + let lt = if last { l.layer_time(l.out_point - fd) } else { l.layer_time(cti) }; + let lin = l.layer_time(l.in_point); + let pr = l.props.get_mut("timeRemap").ok_or(EngineError::NoComp)?; + pr.value = KV::Scalar(*src); + if last { + // Play normally up to the last frame, then hold it to the end of the comp. + let mut end = Keyframe::new(lt.max(lin), KV::Scalar(*src)); + end.out_interp = effectcraft_keyframe::Interp::Hold; + pr.keys = vec![Keyframe::new(lin.min(lt), KV::Scalar(lin.min(lt).seconds())), end]; + pr.keys.dedup_by_key(|k| k.time); + l.out_point = cdur; + } else { + pr.keys = vec![Keyframe::new(lt, KV::Scalar(*src)).hold()]; + } + } + proj.next_id = next; + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.timeStretch", + "Time Stretch…", + ["Layer", "Time"], + None, + "{layers?, percent?|duration? (s), hold?: in|current|out, op?: reverse}", + has_layers, + time_stretch + ), + cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["op"] = json!("reverse"); + time_stretch(s, &p) + }), + cmd!("layer.enableTimeRemap", "Enable Time Remapping", ["Layer", "Time"], Some("Cmd+Alt+T"), "{layers?, value?}", has_layers, enable_time_remap), + cmd!("layer.freezeFrame", "Freeze Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, false)), + cmd!("layer.freezeOnLastFrame", "Freeze on Last Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, true)), + ] +} diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs new file mode 100644 index 000000000..1e070ab5f --- /dev/null +++ b/crates/engine/src/commands/link.rs @@ -0,0 +1,182 @@ +//! Separate Dimensions and the property pick-whip (AE-style reference expressions). + +use effectcraft_keyframe::{Ease, Interp, Keyframe, Value as KV}; +use effectcraft_project::{Comp, Layer, Node, ParamUi, Property, Uid}; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use super::prop::prop_ref; +use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p}; +use crate::{EngineError, Result, Session, cmd}; + +const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")]; + +/// Per-dimension scalar keys from a (spatial) position key list: same times and interpolation; +/// Bezier sides get the per-dimension velocity the combined property had at the key. +fn split_keys(keys: &[Keyframe], spatial: bool, d: usize) -> Vec { + let h = Tick(TICKS_PER_SECOND / 2000); + keys.iter() + .map(|k| { + let mut s = Keyframe::new(k.time, KV::Scalar(k.value.components().get(d).copied().unwrap_or(0.0))); + s.in_interp = k.in_interp; + s.out_interp = k.out_interp; + s.auto_bezier = k.auto_bezier; + s.continuous = k.continuous; + s.roving = false; + let vel = |t: Tick| effectcraft_keyframe::velocity(keys, t, spatial).get(d).copied().unwrap_or(0.0); + if k.in_interp == Interp::Bezier { + let inf = k.in_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0); + s.in_ease = vec![Ease { speed: vel(k.time - h), influence: inf }]; + } + if k.out_interp == Interp::Bezier { + let inf = k.out_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0); + s.out_ease = vec![Ease { speed: vel(k.time + h), influence: inf }]; + } + s + }) + .collect() +} + +fn separate(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "prop.separateDimensions")?; + let want = b_p(p, "value"); + let on = s.edit("Separate Dimensions", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let tr = l.props.sub_mut("transform").ok_or_else(|| bad("prop.separateDimensions", "this layer has no Transform"))?; + let has = tr.get("positionX").is_some(); + let target = want.unwrap_or(!has); + if target && !has { + let pos = tr.get("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?.clone(); + let at = tr.children.iter().position(|c| c.uid() == pos.uid).map(|i| i + 1).unwrap_or(tr.children.len()); + let comps = pos.value.components(); + for (d, (m, name)) in AXES.iter().enumerate().rev() { + let mut pr = Property::new(next, m, name, KV::Scalar(comps.get(d).copied().unwrap_or(0.0))).with_ui(ParamUi::Number); + next += 1; + pr.three_d_only = d == 2; + pr.keys = split_keys(&pos.keys, pos.spatial, d); + tr.children.insert(at, Node::Prop(pr)); + } + } else if !target && has { + let parts: Vec = AXES.iter().filter_map(|(m, _)| tr.get(m).cloned()).collect(); + let mut times: Vec = parts.iter().flat_map(|p| p.keys.iter().map(|k| k.time)).collect(); + times.sort(); + times.dedup(); + let at = |t: Tick| KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0))); + let pos = tr.get_mut("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?; + pos.value = at(Tick::ZERO); + pos.keys = times.iter().map(|t| Keyframe::new(*t, at(*t))).collect(); + if pos.keys.is_empty() { + pos.value = KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value.as_f64()).unwrap_or(0.0))); + } + tr.children.retain(|c| !AXES.iter().any(|(m, _)| c.match_id() == *m)); + } + proj.next_id = next; + Ok(target) + })?; + Ok(json!(on)) +} + +// ---------------------------------------------------------------- pick-whip + +/// `X Position` → `xPosition`, `Mask Path` → `maskPath`. +fn camel(name: &str) -> String { + let mut out = String::new(); + for (i, w) in name.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).enumerate() { + let mut cs = w.chars(); + if let Some(f) = cs.next() { + if i == 0 { + out.extend(f.to_lowercase()); + } else { + out.extend(f.to_uppercase()); + } + out.push_str(cs.as_str()); + } + } + out +} + +fn quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\"")) +} + +/// The AE expression reference to property `uid` of `target` as seen from a property of `from`. +pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option { + let chain = target.props.node_chain(uid)?; + let mut parts: Vec = vec![]; + let mut pending: Option<&str> = None; + let mut in_effect = false; + for n in chain { + if let Some(f) = pending.take() { + parts.push(format!("{f}({})", quote(n.name()))); + in_effect |= f == "effect"; + continue; + } + match (n, n.match_id()) { + (Node::Group(_), "masks") => pending = Some("mask"), + (Node::Group(_), "effects") => pending = Some("effect"), + (Node::Group(_), "contents") => pending = Some("content"), + (Node::Group(_), "transform") if !in_effect => parts.push("transform".into()), + (Node::Group(_), "text") if !in_effect => parts.push("text".into()), + (Node::Prop(_), "sourceText") => parts.push("sourceText".into()), + _ if in_effect => parts.push(format!("({})", quote(n.name()))), + _ => parts.push(camel(n.name())), + } + } + let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) }; + let _ = comp; + for (i, p) in parts.iter().enumerate() { + if i > 0 && !p.starts_with('(') { + out.push('.'); + } + out.push_str(p); + } + Some(out) +} + +/// Dimensions an expression sees (2D layers see 2D Position/Scale/Anchor). +fn dims(l: &Layer, p: &Property) -> usize { + match &p.value { + KV::Vec3(_) if p.shown_dims == 2 && !l.is_3d() => 2, + v => v.components().len().max(1), + } +} + +fn pick_whip(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "prop.pickWhip")?; + let t = p.get("target").ok_or_else(|| bad("prop.pickWhip", "missing `target` {layer, path|prop}"))?; + let (_, tl, tu) = prop_ref(s, t, "prop.pickWhip")?; + if (tl, tu) == (lid, uid) { + return Err(bad("prop.pickWhip", "a property can't reference itself")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let from = comp.layer(lid).ok_or(EngineError::NoComp)?; + let target = comp.layer(tl).ok_or(EngineError::NoComp)?; + let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); + let (dm, dt) = (dims(from, me), dims(target, them)); + let text = if dt == 1 && dm > 1 { + format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", ")) + } else if dt > 1 && dm == 1 { + format!("{r}[0]") + } else { + r + }; + s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?; + Ok(json!(text)) +} + +pub fn specs() -> Vec { + vec![ + cmd!("prop.separateDimensions", "Separate Dimensions", ["Animation"], None, "{layer?, value?}", has_layers, separate), + cmd!( + "prop.pickWhip", + "Pick Whip (Link Property)", + [], + None, + "{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression", + has_layers, + pick_whip + ), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a86..bb5dbd075 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,12 +1,15 @@ //! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, //! shortcut and viewer/timeline gesture maps to one of these. +mod anim; mod comp; mod edit; mod effect; mod file; mod help; mod layer; +mod layer_time; +mod link; mod prop; mod query; mod time; @@ -68,7 +71,10 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(comp::specs()); v.extend(layer::specs()); + v.extend(layer_time::specs()); v.extend(prop::specs()); + v.extend(anim::specs()); + v.extend(link::specs()); v.extend(effect::specs()); v.extend(time::specs()); v.extend(help::specs()); @@ -182,6 +188,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { s.time() } -pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> { +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) } diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 43bd88e6d..8320cf047 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -9,7 +9,7 @@ use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mu use crate::{EngineError, KeyRef, Result, Session, cmd}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). -fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { +pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { let (cid, lid) = layer_p(s, p, cmd)?; let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; if let Some(u) = p.get("prop").and_then(Value::as_u64) { @@ -40,6 +40,32 @@ fn with_prop( fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; + // Position with Separate Dimensions: write the X/Y/Z Position properties instead. + if let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) + && let Some(tr) = l.transform() + && tr.get("position").is_some_and(|pr| pr.uid == uid) + && tr.get("positionX").is_some() + && let Some(Value::Array(a)) = p.get("value") + { + let mut out = vec![]; + let three = l.is_3d(); + for (d, m) in ["positionX", "positionY", "positionZ"].iter().enumerate() { + if d == 2 && (!three || a.len() < 3) { + continue; + } + if let Some(v) = a.get(d) { + let mut q = p.clone(); + q["path"] = json!(format!("transform/{m}")); + q["layer"] = json!(lid.0); + if let Some(o) = q.as_object_mut() { + o.remove("prop"); + } + q["value"] = v.clone(); + out.push(set(s, &q)?); + } + } + return Ok(json!(out)); + } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); let at = f_p(p, "time").map(Tick::from_seconds_f64); let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { @@ -113,8 +139,16 @@ fn toggle_key(s: &mut Session, p: &Value) -> Result { fn set_expr(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?; let text = p.get("expression").and_then(Value::as_str).map(str::to_string); - let enabled = b_p(p, "enabled"); - with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { + let mut enabled = b_p(p, "enabled"); + // A syntax error keeps the text but disables the expression (AE shows the warning bar). + let error = match (&text, s.expr_check) { + (Some(t), Some(check)) if !t.trim().is_empty() => check(t).err(), + _ => None, + }; + if error.is_some() { + enabled = Some(false); + } + let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { match (&text, enabled) { (Some(t), _) if t.trim().is_empty() => pr.expr = None, (Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }), @@ -130,7 +164,12 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { } } Ok(json!(pr.expr.as_ref().map(|e| e.text.clone()))) - }) + })?; + if let Some(e) = error { + s.toast(format!("Expression disabled: {e}")); + return Ok(json!({"expression": r, "error": e})); + } + Ok(r) } fn default_expr(pr: &Property) -> String { @@ -210,7 +249,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { } /// Apply `f` to every selected key; keeps the selection pointing at moved keys. -fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec, usize, &mut Tick) -> bool) -> Result { +pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec, usize, &mut Tick) -> bool) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let sel = s.state.selected_keys.clone(); s.edit(label, merge, |proj, st| { @@ -247,8 +286,14 @@ fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut for t in keep_times { new_sel.push(KeyRef { layer: lid, prop: uid, time: t }); } - if pr.keys.is_empty() { - // Deleting every key leaves the value at the last key. + // Roving keys follow their neighbours: re-time and keep the selection on them. + let before: Vec = pr.keys.iter().map(|k| k.time).collect(); + if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { + for r in new_sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { + if let Some(i) = before.iter().position(|t| *t == r.time) { + r.time = pr.keys[i].time; + } + } } } st.selected_keys = new_sel; @@ -304,8 +349,56 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { let both = parse("interpolation"); let inn = parse("in").or(both); let out = parse("out").or(both); - let auto = b_p(p, "autoBezier"); + // Temporal interpolation by dialog name: linear|bezier|continuousBezier|autoBezier|hold. + let temporal = str_p(p, "interpolation").map(|v| v.to_ascii_lowercase()); + let mut auto = b_p(p, "autoBezier"); + let mut continuous = b_p(p, "continuous"); + match temporal.as_deref() { + Some("autobezier" | "auto") => auto = Some(true), + Some("continuousbezier" | "continuous") => { + continuous = Some(true); + auto = Some(false); + } + Some("linear" | "bezier" | "hold") => { + auto = auto.or(Some(false)); + continuous = continuous.or(Some(false)); + } + _ => {} + } + let inn = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { inn.or(Some(Interp::Bezier)) } else { inn }; + let out = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { out.or(Some(Interp::Bezier)) } else { out }; + let spatial = str_p(p, "spatial").map(|v| v.to_ascii_lowercase()); + let roving = b_p(p, "roving"); edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| { + if let Some(sp) = spatial.as_deref() { + let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i); + let k = &mut keys[i]; + match sp { + "linear" => { + k.spatial_auto = false; + k.spatial_continuous = false; + k.spatial_in = [0.0; 3]; + k.spatial_out = [0.0; 3]; + } + "bezier" | "continuous" | "continuousbezier" => { + k.spatial_auto = false; + k.spatial_in = tin; + k.spatial_out = tout; + k.spatial_continuous = sp != "bezier"; + } + "auto" | "autobezier" => { + k.spatial_auto = true; + k.spatial_continuous = false; + } + _ => {} + } + } + if let Some(r) = roving { + keys[i].roving = r; + } + if let Some(c) = continuous { + keys[i].continuous = c; + } let n = keys[i].value.dims().max(1); if let Some(x) = inn { keys[i].in_interp = x; @@ -324,6 +417,24 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { if a { keys[i].in_interp = Interp::Bezier; keys[i].out_interp = Interp::Bezier; + keys[i].continuous = false; + } + } + if keys[i].continuous && !keys[i].auto_bezier { + // Continuous Bezier: one speed through the key (average of both sides). + let k = &mut keys[i]; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + if k.in_ease.len() != n { + k.in_ease = vec![Ease::default(); n]; + } + if k.out_ease.len() != n { + k.out_ease = vec![Ease::default(); n]; + } + for d in 0..n { + let sp = (k.in_ease[d].speed + k.out_ease[d].speed) / 2.0; + k.in_ease[d].speed = sp; + k.out_ease[d].speed = sp; } } false @@ -338,41 +449,67 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +/// A number or a per-dimension array of numbers. +fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { + match p.get(k)? { + Value::Array(a) => Some(a.iter().filter_map(Value::as_f64).map(|v| v * scale).collect()), + v => v.as_f64().map(|v| vec![v * scale]), + } +} + fn velocity(s: &mut Session, p: &Value) -> Result { - let in_speed = f_p(p, "inSpeed"); - let in_inf = f_p(p, "inInfluence").map(|v| v / 100.0); - let out_speed = f_p(p, "outSpeed"); - let out_inf = f_p(p, "outInfluence").map(|v| v / 100.0); + let mut in_speed = nums_p(p, "inSpeed", 1.0); + let mut in_inf = nums_p(p, "inInfluence", 0.01); + let mut out_speed = nums_p(p, "outSpeed", 1.0); + let mut out_inf = nums_p(p, "outInfluence", 0.01); + // "Continuous" in the dialog: outgoing velocity follows incoming. + let continuous = b_p(p, "continuous"); + if continuous == Some(true) { + if out_speed.is_none() { + out_speed = in_speed.clone(); + } + if in_speed.is_none() { + in_speed = out_speed.clone(); + } + if out_inf.is_none() { + out_inf = in_inf.clone(); + } + if in_inf.is_none() { + in_inf = out_inf.clone(); + } + } edit_keys(s, "Keyframe Velocity", merge_p(p), move |keys, i, _| { let n = keys[i].value.dims().max(1); + // Current presentation of each side, so unspecified parts keep their values. + let cur = |keys: &[Keyframe], out: bool| -> Vec { + (0..n).map(|d| effectcraft_keyframe::side_ease(keys, i, d, false, out).unwrap_or(Ease::EASY)).collect() + }; + let cur_in = cur(keys, false); + let cur_out = cur(keys, true); let k = &mut keys[i]; - if in_speed.is_some() || in_inf.is_some() { - k.in_interp = Interp::Bezier; - if k.in_ease.is_empty() { - k.in_ease = vec![Ease::default(); n]; + let apply = |list: &mut Vec, base: Vec, sp: &Option>, inf: &Option>| { + if list.len() != n { + *list = base; } - for e in &mut k.in_ease { - if let Some(v) = in_speed { - e.speed = v; + for (d, e) in list.iter_mut().enumerate() { + if let Some(v) = sp.as_ref().and_then(|v| v.get(d).or(v.first())) { + e.speed = *v; } - if let Some(v) = in_inf { + if let Some(v) = inf.as_ref().and_then(|v| v.get(d).or(v.first())) { e.influence = v.clamp(0.001, 1.0); } } + }; + if in_speed.is_some() || in_inf.is_some() { + k.in_interp = Interp::Bezier; + apply(&mut k.in_ease, cur_in, &in_speed, &in_inf); } if out_speed.is_some() || out_inf.is_some() { k.out_interp = Interp::Bezier; - if k.out_ease.is_empty() { - k.out_ease = vec![Ease::default(); n]; - } - for e in &mut k.out_ease { - if let Some(v) = out_speed { - e.speed = v; - } - if let Some(v) = out_inf { - e.influence = v.clamp(0.001, 1.0); - } - } + apply(&mut k.out_ease, cur_out, &out_speed, &out_inf); + } + if let Some(c) = continuous { + k.continuous = c; } k.auto_bezier = false; false @@ -435,7 +572,7 @@ pub fn specs() -> Vec { "Keyframe Interpolation…", ["Animation"], Some("Cmd+Alt+K"), - "{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}", + "{interpolation?: linear|bezier|continuousBezier|autoBezier|hold, in?|out?: linear|bezier|hold, autoBezier?, continuous?, spatial?: linear|bezier|continuousBezier|autoBezier, roving?}", has_keys, interpolation ), @@ -444,7 +581,7 @@ pub fn specs() -> Vec { "Keyframe Velocity…", ["Animation"], Some("Cmd+Shift+K"), - "{inSpeed?, inInfluence? %, outSpeed?, outInfluence? %}", + "{inSpeed?, inInfluence? %, outSpeed?, outInfluence? % (numbers or per-dimension arrays), continuous?}", has_keys, velocity ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18f..9644f5e65 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,24 @@ pub struct KeyRef { pub time: Tick, } +/// Keyframes of one property on the keyframe clipboard. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KeyClip { + pub layer: LayerId, + /// Match-id path relative to the layer (`transform/position`), portable between layers. + pub path: String, + /// Keys with times relative to the earliest copied key (layer time). + pub keys: Vec, +} + +/// A selected mask vertex: layer, mask group uid, vertex index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VertexRef { + pub layer: LayerId, + pub mask: Uid, + pub index: usize, +} + /// Editing state that commands depend on (headless-relevant, serde for agents). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -107,6 +125,15 @@ pub struct EditorState { /// Layer clipboard (serialized layers) and keyframe clipboard. #[serde(skip)] pub clipboard: Vec, + /// Copied keyframes (times relative to the earliest copied key). + #[serde(skip)] + pub key_clipboard: Vec, + /// The last copy was keyframes (Paste pastes keys at the CTI). + #[serde(skip)] + pub clip_is_keys: bool, + /// Selected mask vertices (viewer Selection tool / pen). + #[serde(default)] + pub selected_vertices: Vec, pub snapping: bool, /// Last applied effect id (Effect ▸ last effect). pub last_effect: Option, @@ -131,6 +158,8 @@ pub struct Session { pub services: Arc, pub footage: Arc, pub expr: Option>, + /// Expression syntax checker (set by the host that links the expression engine). + pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, pub events: Vec, /// Commands executed: (id, params). @@ -149,6 +178,7 @@ impl Default for Session { services: Arc::new(FsServices), footage: Arc::new(NoFootage), expr: None, + expr_check: None, importer: None, events: vec![], journal: vec![], @@ -243,6 +273,7 @@ impl Session { self.state.selected_layers.retain(|l| ids.contains(l)); self.state.selected_props.retain(|(l, _)| ids.contains(l)); self.state.selected_keys.retain(|k| ids.contains(&k.layer)); + self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); } self.state.project_selection.retain(|i| p.item(*i).is_some()); } @@ -287,6 +318,7 @@ impl Session { self.state.selected_layers.clear(); self.state.selected_props.clear(); self.state.selected_keys.clear(); + self.state.selected_vertices.clear(); } self.events.push(Event::OpenComp(id)); } @@ -324,6 +356,8 @@ impl Session { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_timeline; /// Font families available to text layers (bundled + scanned system fonts). pub fn text_families() -> Vec { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs new file mode 100644 index 000000000..315082de1 --- /dev/null +++ b/crates/engine/src/tests_timeline.rs @@ -0,0 +1,454 @@ +//! Timeline depth: keyframe clipboard, easing, velocity/interpolation, roving, nudging, graph +//! editor key edits, time remapping/stretch/reverse/freeze, separate dimensions, pick-whips, +//! expressions and masks (pen tool). Every command is checked with undo/redo. + +use effectcraft_keyframe::{Interp, Value as KV}; +use effectcraft_project::Property; +use serde_json::{Value, json}; + +use crate::Session; + +/// A 4 s, 30 fps comp with one solid; returns (session, layer id). +pub(crate) fn setup() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 100, "height": 100})).unwrap(); + (s, r["layer"].as_u64().unwrap()) +} + +pub(crate) fn prop(s: &Session, layer: u64, path: &str) -> Property { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().props.prop(path).unwrap().clone() +} + +/// Key `path` at the given (time, value) pairs. +pub(crate) fn animate(s: &mut Session, layer: u64, path: &str, keys: &[(f64, Value)]) { + for (t, v) in keys { + s.execute("prop.addKey", json!({"layer": layer, "path": path, "time": t, "value": v})).unwrap(); + } +} + +fn select_prop_keys(s: &mut Session, layer: u64, path: &str) { + s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap(); +} + +fn undo_redo_roundtrip(s: &mut Session, check_after: impl Fn(&Session), check_before: impl Fn(&Session)) { + check_after(s); + s.execute("edit.undo", json!({})).unwrap(); + check_before(s); + s.execute("edit.redo", json!({})).unwrap(); + check_after(s); +} + +#[test] +fn easy_ease_family_sets_influence_and_zero_speed() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("keys.easyEase", json!({})).unwrap(); + undo_redo_roundtrip( + &mut s, + |s| { + let p = prop(s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Bezier)); + assert!((p.keys[1].in_ease[0].influence - 1.0 / 3.0).abs() < 1e-9 && p.keys[1].in_ease[0].speed == 0.0); + }, + |s| assert!(prop(s, l, "transform/opacity").keys.iter().all(|k| k.in_interp == Interp::Linear)), + ); + // Easy Ease In only touches the incoming side; Out only the outgoing side. + s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap(); + s.execute("keys.easyEaseIn", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Linear)); + s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap(); + s.execute("keys.easyEaseOut", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Bezier)); + // Eased motion is slow near the key. + s.execute("keys.easyEase", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + let early = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.1)).as_f64(); + assert!(early < 5.0, "{early}"); +} + +#[test] +fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/rotation", &[(0.5, json!(0)), (1.0, json!(90)), (2.0, json!(45))]); + select_prop_keys(&mut s, l, "transform/rotation"); + s.execute("keys.easyEase", json!({})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(3)); + assert!(s.state.clip_is_keys); + // Paste into the same property at 2.5 s: keys at 2.5, 3.0 and 4.0 (clamped by nothing). + s.execute("time.set", json!({"time": 2.5})).unwrap(); + s.execute("keys.select", json!({"keys": []})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/rotation"); + let times: Vec = p.keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect(); + assert_eq!(times, vec![0.5, 1.0, 2.0, 2.5, 3.0, 4.0]); + assert_eq!(p.keys[4].value, KV::Scalar(90.0)); + assert_eq!(p.keys[4].in_interp, Interp::Bezier, "interpolation travels with the keys"); + assert_eq!(s.state.selected_keys.len(), 3, "pasted keys are selected"); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/rotation").keys.len(), 3)); + // Cross-property paste of one property's keys into another of the same type (Scalar). + let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let l2 = r["layer"].as_u64().unwrap(); + select_prop_keys(&mut s, l, "transform/rotation"); + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/rotation").uid, "time": 1.0}]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": l2, "path": "transform/opacity", "selectKeys": false})).unwrap(); + s.execute("keys.paste", json!({"layers": [l2]})).unwrap(); + let op = prop(&s, l2, "transform/opacity"); + assert_eq!(op.keys.len(), 1); + assert_eq!(op.keys[0].value, KV::Scalar(90.0)); + // Incompatible target (Vec3 position) is refused when explicitly given. + let e = s.execute("keys.paste", json!({"layers": [l2], "path": "transform/position"})); + assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty()); +} + +#[test] +fn cut_removes_keys_and_paste_restores_them() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("edit.cut", json!({})).unwrap(); + assert!(prop(&s, l, "transform/opacity").keys.is_empty()); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 2); +} + +#[test] +fn select_all_and_nudge_keys() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.5, json!(10))]); + s.state.selected_props.clear(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + assert_eq!(s.execute("keys.selectAll", json!({})).unwrap(), json!(3)); + s.execute("keys.nudgeForward", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/opacity"); + assert!((p.keys[0].time.seconds() - 1.0 / 30.0).abs() < 1e-6); + assert_eq!(s.state.selected_keys.len(), 3); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].time.0, 0)); + s.execute("keys.nudgeBackward10", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!((p.keys[0].time.seconds() + 9.0 / 30.0).abs() < 1e-6, "{}", p.keys[0].time.seconds()); +} + +#[test] +fn velocity_dialog_values_per_dimension_and_continuous() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (2.0, json!([100, 100]))]); + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/scale").uid, "time": 1.0}]})).unwrap(); + let info = s.execute("keys.info", json!({})).unwrap(); + assert_eq!(info[0]["units"], "%/sec"); + assert_eq!(info[0]["dims"], 2); + // Linear sides present the segment slope: +100 %/s in X on the incoming side. + assert!((info[0]["inEase"][0]["speed"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{info}"); + s.execute("keys.velocity", json!({"inSpeed": [10, -20], "inInfluence": 50, "continuous": true})).unwrap(); + let after = |s: &Session| { + let k = &prop(s, l, "transform/scale").keys[1]; + assert_eq!(k.in_ease[0].speed, 10.0); + assert_eq!(k.in_ease[1].speed, -20.0); + assert_eq!(k.out_ease[1].speed, -20.0, "continuous mirrors incoming"); + assert!((k.out_ease[0].influence - 0.5).abs() < 1e-9); + assert!(k.continuous); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); +} + +#[test] +fn interpolation_dialog_temporal_spatial_and_roving() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]); + select_prop_keys(&mut s, l, "transform/position"); + s.execute("keys.interpolation", json!({"interpolation": "hold"})).unwrap(); + assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.out_interp == Interp::Hold)); + s.execute("keys.interpolation", json!({"interpolation": "autoBezier"})).unwrap(); + assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.auto_bezier)); + s.execute("keys.interpolation", json!({"interpolation": "continuousBezier", "spatial": "linear"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| k.continuous && !k.auto_bezier && !k.spatial_auto && k.spatial_out == [0.0; 3])); + // Rove the middle key: its time follows the path length (300 of 400 px → 1.5 s). + let uid = p.uid; + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap(); + s.execute("keys.interpolation", json!({"roving": true})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/position"); + assert!(p.keys[1].roving); + assert!((p.keys[1].time.seconds() - 1.5).abs() < 0.01, "{}", p.keys[1].time.seconds()); + // The selection follows the roving key. + assert!((s.state.selected_keys[0].time.seconds() - 1.5).abs() < 0.01); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(!prop(s, l, "transform/position").keys[1].roving)); + // Moving the last key re-times the roving key. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 4.0})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!((p.keys[1].time.seconds() - 3.0).abs() < 0.02, "{}", p.keys[1].time.seconds()); + // Toggle hold on a key and back. + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}]})).unwrap(); + s.execute("keys.toggleHold", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); + s.execute("keys.toggleHold", json!({})).unwrap(); + assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); +} + +#[test] +fn graph_editor_key_and_handle_edits() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (2.0, json!(100))]); + // Drag a key in the value graph: new time + value, merged into one undo step. + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 2.0, "newTime": 1.0, "value": 50, "merge": "g1"})).unwrap(); + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "newTime": 1.5, "value": 60, "merge": "g1"})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!((p.keys[1].time.seconds() - 1.5).abs() < 1e-6); + assert_eq!(p.keys[1].value, KV::Scalar(60.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert!((prop(&s, l, "transform/opacity").keys[1].time.seconds() - 2.0).abs() < 1e-6); + s.execute("edit.redo", json!({})).unwrap(); + // A key can't be dragged past its neighbour. + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.5, "newTime": -1.0})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys[1].time > p.keys[0].time); + // Handle drag: out side of key 0 → speed 200 %/s, 60 % influence. + s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 0.0, "side": "out", "speed": 200, "influence": 60})).unwrap(); + let k = &prop(&s, l, "transform/opacity").keys[0]; + assert_eq!(k.out_interp, Interp::Bezier); + assert_eq!(k.out_ease[0].speed, 200.0); + assert!((k.out_ease[0].influence - 0.6).abs() < 1e-9); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); +} + +#[test] +fn time_reverse_keyframes() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(30)), (3.0, json!(100))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("keys.timeReverse", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/opacity"); + assert_eq!(p.keys[0].value, KV::Scalar(100.0)); + assert!((p.keys[1].time.seconds() - 2.0).abs() < 1e-6); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0))); +} + +#[test] +fn separate_dimensions_split_and_rejoin() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 100])), (2.0, json!([200, 50]))]); + s.execute("prop.separateDimensions", json!({"layer": l})).unwrap(); + let after = |s: &Session| { + let x = prop(s, l, "transform/positionX"); + let y = prop(s, l, "transform/positionY"); + assert_eq!(x.keys.len(), 2); + assert_eq!(y.keys[1].value, KV::Scalar(50.0)); + // The layer still renders at the same place (render sees the separated values). + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::from_seconds_f64(1.0)); + let p = ctx.value(layer, layer.transform().unwrap().get("position").unwrap()).as_vec3(); + assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 75.0).abs() < 1e-6, "{p:?}"); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none())); + // X and Y animate independently. + s.execute("keys.select", json!({"keys": []})).unwrap(); + s.execute("prop.select", json!({"layer": l, "path": "transform/positionX"})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/positionY").keys[0].out_interp, Interp::Linear); + // Setting Position (viewer drags) writes X/Y. + s.execute("time.set", json!({"time": 3.0})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [7, 8, 0]})).unwrap(); + assert_eq!(prop(&s, l, "transform/positionX").keys.len(), 3); + // Re-join: one Position with keys at the union of times. + s.execute("prop.separateDimensions", json!({"layer": l})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert_eq!(p.keys.len(), 3); + assert_eq!(p.keys[2].value, KV::Vec3([7.0, 8.0, 0.0])); + assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none()); +} + +#[test] +fn parenting_keeps_the_layer_in_place() { + let (mut s, child) = setup(); + let r = s.execute("layer.newNull", json!({})).unwrap(); + let par = r["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/position", "value": [300, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/rotation", "value": 90})).unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/scale", "value": [200, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100, 50]})).unwrap(); + let world = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let l = comp.layer(effectcraft_project::LayerId(child)).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::ZERO); + let (m, _) = ctx.layer_to_comp(l); + let a = m.apply(effectcraft_geom::vec2(50.0, 50.0)); + let b = m.apply(effectcraft_geom::vec2(100.0, 50.0)); + (a, (b.y - a.y).atan2(b.x - a.x).to_degrees(), (b.x - a.x).hypot(b.y - a.y)) + }; + let before = world(&s); + s.execute("layer.setParent", json!({"layers": [child], "parent": par})).unwrap(); + let after = world(&s); + assert!((before.0.x - after.0.x).abs() < 1e-6 && (before.0.y - after.0.y).abs() < 1e-6, "{before:?} {after:?}"); + assert!((before.1 - after.1).abs() < 1e-6 && (before.2 - after.2).abs() < 1e-6); + assert!((prop(&s, child, "transform/rotation").value.as_f64() + 90.0).abs() < 1e-9); + // Unparenting restores comp-space values. + s.execute("layer.setParent", json!({"layers": [child], "parent": null})).unwrap(); + let p = prop(&s, child, "transform/position").value.as_vec3(); + assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 50.0).abs() < 1e-6, "{p:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(child)).unwrap().parent.is_some()); +} + +#[test] +fn pick_whip_generates_ae_reference_expressions() { + let (mut s, l) = setup(); + let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap(); + let o = r["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": o, "effect": "Gaussian Blur"})).unwrap(); + s.execute("layer.addMask", json!({"layer": o})).unwrap(); + let cases = [ + ("transform/opacity", json!({"layer": o, "path": "transform/opacity"}), "thisComp.layer(\"Other\").transform.opacity"), + ("transform/rotation", json!({"layer": l, "path": "transform/opacity"}), "transform.opacity"), + ("transform/rotation", json!({"layer": o, "path": "effects/#1/blurriness"}), "thisComp.layer(\"Other\").effect(\"Gaussian Blur\")(\"Blurriness\")"), + ("transform/opacity", json!({"layer": o, "path": "masks/#1/feather"}), "thisComp.layer(\"Other\").mask(\"Mask 1\").maskFeather[0]"), + ("transform/scale", json!({"layer": o, "path": "transform/rotation"}), "temp = thisComp.layer(\"Other\").transform.rotation;\n[temp, temp]"), + ("transform/position", json!({"layer": o, "path": "transform/position"}), "thisComp.layer(\"Other\").transform.position"), + ]; + for (path, target, want) in cases { + let got = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": target})).unwrap(); + assert_eq!(got, json!(want)); + assert_eq!(prop(&s, l, path).expr.unwrap().text, want); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(prop(&s, l, "transform/position").expr.map(|e| e.text), Some("thisComp.layer(\"Other\").transform.position".into())); + assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); +} + +/// Outer comp (4 s) containing a precomp whose 10×10 white solid moves x = 10 + 20·t (t in s). +/// Returns (session, precomp layer id in the outer comp). +fn precomp_setup() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap(); + let l = r["layer"].as_u64().unwrap(); + animate(&mut s, l, "transform/position", &[(0.0, json!([10, 50])), (4.0, json!([90, 50]))]); + s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap(); + let pl = r["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [pl]})).unwrap(); + (s, pl) +} + +/// X of the moving square's centre in the outer comp at `t`. +fn square_x(s: &Session, t: f64) -> f64 { + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() }); + let xs: Vec = (0..img.width as usize).filter(|x| img.get(*x as i64, 50)[3] > 0.5).collect(); + assert!(!xs.is_empty(), "nothing drawn at {t}"); + (xs[0] + xs[xs.len() - 1]) as f64 / 2.0 + 0.5 +} + +#[test] +fn time_remap_enable_freeze_and_disable() { + let (mut s, l) = precomp_setup(); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "timeRemap"); + assert_eq!(p.keys.len(), 2); + assert_eq!(p.keys[1].value, KV::Scalar(4.0)); + // Remapping at identity shows the same frames. + assert!((square_x(s, 1.0) - 30.0).abs() < 1.5); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].props.get("timeRemap").is_none())); + // Remap the first key to show source time 3 s at comp time 0. + s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 3.0})).unwrap(); + assert!((square_x(&s, 0.0) - 70.0).abs() < 1.5, "{}", square_x(&s, 0.0)); + // Freeze Frame at 2 s: one hold key; every frame shows source time 2 s. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 0.0})).unwrap(); + s.execute("layer.freezeFrame", json!({})).unwrap(); + let p = prop(&s, l, "timeRemap"); + assert_eq!(p.keys.len(), 1); + assert_eq!(p.keys[0].out_interp, Interp::Hold); + for t in [0.0, 1.0, 3.5] { + assert!((square_x(&s, t) - 50.0).abs() < 1.5, "{t}: {}", square_x(&s, t)); + } + // Ctrl+Alt+T again disables remapping. + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + // Solids can't be remapped. + let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err()); +} + +#[test] +fn freeze_on_last_frame_extends_the_layer() { + let (mut s, l) = precomp_setup(); + s.execute("layer.timing", json!({"layers": [l], "out": 2.0})).unwrap(); + s.execute("layer.freezeOnLastFrame", json!({})).unwrap(); + let layer = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(layer.out_point, s.active_comp().unwrap().duration); + // Plays normally, then holds the frame before the old out point. + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + let hold = 10.0 + 20.0 * (2.0 - 1.0 / 30.0); + assert!((square_x(&s, 3.5) - hold).abs() < 1.5, "{}", square_x(&s, 3.5)); +} + +#[test] +fn time_reverse_layer_plays_backwards_and_round_trips() { + let (mut s, l) = precomp_setup(); + let before = s.active_comp().unwrap().layers[0].clone(); + s.execute("layer.timeReverse", json!({})).unwrap(); + let after = |s: &Session| { + let ly = &s.active_comp().unwrap().layers[0]; + assert_eq!(ly.stretch, -100.0); + assert_eq!((ly.in_point, ly.out_point), (before.in_point, before.out_point)); + // First frame shows the last source frame; the end shows the start. + let last = 10.0 + 20.0 * (4.0 - 1.0 / 30.0); + assert!((square_x(s, 0.0) - last).abs() < 1.5, "{}", square_x(s, 0.0)); + assert!((square_x(s, 4.0 - 1.0 / 30.0) - 10.0).abs() < 1.5); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(s.active_comp().unwrap().layers[0].stretch, 100.0)); + // Reversing twice restores normal playback. + s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap(); + let ly = &s.active_comp().unwrap().layers[0]; + assert_eq!(ly.stretch, 100.0); + assert_eq!(ly.start_time, before.start_time); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); +} + +#[test] +fn time_stretch_holds_in_current_or_out() { + let (mut s, l) = precomp_setup(); + s.execute("layer.timeStretch", json!({"percent": 50})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(ly.stretch, 50.0); + assert!((ly.out_point.seconds() - 2.0).abs() < 1e-6); + // Twice as fast: comp 1 s shows source 2 s. + assert!((square_x(&s, 1.0) - 50.0).abs() < 1.5); + s.execute("edit.undo", json!({})).unwrap(); + // Hold the current frame (2 s) in place while doubling the duration. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [l], "duration": 8.0, "hold": "current"})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert!((ly.stretch - 200.0).abs() < 1e-6); + assert!((ly.in_point.seconds() + 2.0).abs() < 1e-6, "{}", ly.in_point.seconds()); + assert!((square_x(&s, 2.0) - 50.0).abs() < 1.5); + // Hold the out point. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [l], "percent": 50, "hold": "out"})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert!((ly.out_point.seconds() - 4.0).abs() < 1e-6 && (ly.in_point.seconds() - 2.0).abs() < 1e-6); + assert!(s.execute("layer.timeStretch", json!({"layers": [l], "percent": 0})).is_err()); +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index ca9f95e45..eb9dfb145 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -27,6 +27,9 @@ function __begin(t, v, c, l, path, uid, idx, fd) { function __finish(r) { r = __v(r); if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while + // host requests can still be answered and the script re-run. + if (Array.isArray(r)) return r.map(function (x) { return __v(x); }); return r; } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f542..66b39ed5c 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -16,11 +16,13 @@ impl Importer for MediaImporter { /// A new session with media, import and expressions enabled. pub fn session() -> Session { - let mut s = Session::default(); - s.footage = Arc::new(effectcraft_media::MediaPool::new()); - s.importer = Some(Arc::new(MediaImporter)); - s.expr = Some(Arc::new(effectcraft_expr::Expressions)); - s + Session { + footage: Arc::new(effectcraft_media::MediaPool::new()), + importer: Some(Arc::new(MediaImporter)), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + expr_check: Some(effectcraft_expr::check_syntax), + ..Session::default() + } } #[cfg(test)] @@ -37,4 +39,61 @@ mod tests { let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() }); assert!(img.data.iter().any(|p| p[3] > 0.5)); } + + /// Value of a property of layer `l` at time 0 (keys + expression). + fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_engine::keyframe::Value { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); + let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() }; + ctx.value(layer, layer.props.prop(path).unwrap()) + } + + #[test] + fn pick_whip_expressions_evaluate() { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "C", "width": 200, "height": 200, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "Src", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 33})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [12, 34]})).unwrap(); + s.execute("effect.apply", json!({"layer": b, "effect": "Gaussian Blur"})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "effects/#1/blurriness", "value": 21})).unwrap(); + s.execute("layer.addMask", json!({"layer": b})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "masks/#1/feather", "value": [7, 7]})).unwrap(); + let link = |s: &mut effectcraft_engine::Session, from: &str, to: &str| { + s.execute("prop.pickWhip", json!({"layer": a, "path": from, "target": {"layer": b, "path": to}})).unwrap(); + }; + link(&mut s, "transform/opacity", "transform/rotation"); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 33.0); + link(&mut s, "transform/scale", "transform/rotation"); + let cid = s.active_comp_id().unwrap(); + let ep = effectcraft_expr::eval_property(&s.project, cid, effectcraft_engine::project::LayerId(a), "transform/scale", 0.0); + assert!(ep.is_ok(), "{ep:?}"); + assert_eq!(value(&s, a, "transform/scale").as_vec2(), [33.0, 33.0]); + link(&mut s, "transform/position", "transform/position"); + assert_eq!(value(&s, a, "transform/position").as_vec2(), [12.0, 34.0]); + link(&mut s, "transform/rotation", "effects/#1/blurriness"); + assert_eq!(value(&s, a, "transform/rotation").as_f64(), 21.0); + link(&mut s, "transform/opacity", "masks/#1/feather"); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 7.0); + // Separated dimensions: X Position reference. + s.execute("prop.separateDimensions", json!({"layer": b})).unwrap(); + link(&mut s, "transform/rotation", "transform/positionX"); + assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0); + } + + #[test] + fn expression_syntax_errors_disable_the_expression() { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap(); + assert!(r["error"].is_string(), "{r}"); + let pr = s.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().clone(); + assert!(!pr.expr.unwrap().enabled); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 100.0); + s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap(); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0); + } } diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index bb737d1ce..d97ec53d2 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -302,6 +302,110 @@ fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: boo list.get(d).or(list.first()).copied().unwrap_or_default() } +/// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored +/// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight +/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour. +pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option { + let k = keys.get(i)?; + let j = if out { i + 1 } else { i.checked_sub(1)? }; + let n = keys.get(j)?; + let interp = if out { k.out_interp } else { k.in_interp }; + if interp == Interp::Hold || (!out && n.out_interp == Interp::Hold) || !k.value.interpolates() { + return None; + } + let is_spatial = spatial && matches!(k.value, Value::Vec2(_) | Value::Vec3(_)); + if interp == Interp::Linear { + let dur = (secs(k.time) - secs(n.time)).abs().max(1e-12); + let dv = if is_spatial { + let (a, b) = if out { (i, j) } else { (j, i) }; + spatial_segment_length(keys, a.min(b)) + } else { + let a = k.value.components(); + let b = n.value.components(); + let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0); + if out { dv } else { -dv } + }; + return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 }); + } + Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out)) +} + +/// Arc length of the spatial segment `i` → `i + 1` (motion-path length). +pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 { + let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return 0.0 }; + let p0 = v3(&a.value); + let p3 = v3(&b.value); + let (_, out_t) = spatial_tangents(keys, i); + let (in_t, _) = spatial_tangents(keys, i + 1); + SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length() +} + +/// Re-time roving keyframes: each run of roving keys between two fixed (non-roving) keys gets +/// times proportional to the motion-path length, so the layer moves through them at one +/// constant speed (After Effects' "Rove Across Time"). The first and last keys never rove. +/// Returns true when any key time changed. Key order is preserved. +pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { + let n = keys.len(); + if n < 3 { + for k in keys.iter_mut() { + k.roving = false; + } + return false; + } + keys[0].roving = false; + keys[n - 1].roving = false; + if !keys.iter().any(|k| k.roving) { + return false; + } + let mut changed = false; + let mut a = 0; + while a < n - 1 { + let Some(b) = (a + 1..n).find(|&j| !keys[j].roving) else { break }; + if b > a + 1 { + // Cumulative path length (spatial) or value distance (other) of the run a..=b. + let mut acc = vec![0.0]; + for i in a..b { + let len = if spatial && matches!(keys[i].value, Value::Vec2(_) | Value::Vec3(_)) { + spatial_segment_length(keys, i) + } else { + let x = keys[i].value.components(); + let y = keys[i + 1].value.components(); + x.iter().zip(&y).map(|(p, q)| (q - p) * (q - p)).sum::().sqrt() + }; + acc.push(acc.last().unwrap_or(&0.0) + len); + } + let total = *acc.last().unwrap_or(&0.0); + let (t0, t1) = (keys[a].time, keys[b].time); + for (o, i) in (a + 1..b).enumerate() { + let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; + let nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() as i64); + if nt != keys[i].time { + keys[i].time = nt; + changed = true; + } + // Roving keys pass speed straight through. + keys[i].in_interp = Interp::Linear; + keys[i].out_interp = Interp::Linear; + } + } + a = b; + } + changed +} + +/// Reverse a run of keys in time within `[first, last]` (Time-Reverse Keyframes): key `i` moves +/// to `first + last - t` and in/out sides swap. +pub fn time_reverse(keys: &mut [Keyframe]) { + let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return }; + for k in keys.iter_mut() { + k.time = Tick(first.0 + last.0 - k.time.0); + std::mem::swap(&mut k.in_interp, &mut k.out_interp); + std::mem::swap(&mut k.in_ease, &mut k.out_ease); + std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); + } + keys.reverse(); +} + /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { let first = keys.first()?; @@ -524,6 +628,50 @@ mod tests { assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]); } + #[test] + fn roving_keys_get_constant_speed_times() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])), + Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])), + ]; + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + assert!(retime_roving(&mut keys, true)); + // 300 of 400 px → 75 % of the 2 s span. + assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds()); + let v0 = speed(&keys, s(0.5), true); + let v1 = speed(&keys, s(1.8), true); + assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}"); + // First/last keys can't rove. + keys[0].roving = true; + retime_roving(&mut keys, true); + assert!(!keys[0].roving); + } + + #[test] + fn side_ease_reports_linear_and_eased_sides() { + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; + let out = side_ease(&keys, 0, 0, false, true).unwrap(); + assert!((out.speed - 50.0).abs() < 1e-9); + let inn = side_ease(&keys, 1, 0, false, false).unwrap(); + assert_eq!(inn, Ease::EASY); + assert!(side_ease(&keys, 0, 0, false, false).is_none()); + } + + #[test] + fn time_reverse_mirrors_keys() { + let mut keys = + vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))]; + keys[0].out_interp = Interp::Hold; + time_reverse(&mut keys); + assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::>(), vec![1, 3, 4]); + assert_eq!(keys[0].value, Value::Scalar(10.0)); + assert_eq!(keys[2].in_interp, Interp::Hold); + } + proptest::proptest! { #[test] fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) { diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 02706a6d9..a5c685d43 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -352,6 +352,7 @@ impl Value { } } Value::Layer(_) => Some(Value::Layer(j.as_u64())), + Value::Path(_) => serde_json::from_value::(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::(j.clone()).ok()), _ => serde_json::from_value::(j.clone()).ok(), } } diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index df7f997d6..8b6e28ec7 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -399,6 +399,38 @@ impl PropGroup { } None } + /// Match-id path (`transform/position`, `effects/ec.blur.gaussian#2/blurriness`) of the node + /// with `uid`: portable between layers with the same structure (keyframe paste, pick-whip). + pub fn match_path_of(&self, uid: Uid) -> Option { + for (i, c) in self.children.iter().enumerate() { + let n = self.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count() + 1; + let seg = if n == 1 { c.match_id().to_string() } else { format!("{}#{n}", c.match_id()) }; + if c.uid() == uid { + return Some(seg); + } + if let Node::Group(g) = c + && let Some(p) = g.match_path_of(uid) + { + return Some(format!("{seg}/{p}")); + } + } + None + } + /// Chain of nodes from this group's children down to `uid` (inclusive). + pub fn node_chain(&self, uid: Uid) -> Option> { + for c in &self.children { + if c.uid() == uid { + return Some(vec![c]); + } + if let Node::Group(g) = c + && let Some(mut rest) = g.node_chain(uid) + { + rest.insert(0, c); + return Some(rest); + } + } + None + } /// Human path (`Transform/Position`) of the node with `uid`. pub fn name_path_of(&self, uid: Uid) -> Option { for c in &self.children { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 994b60389..547e2e0ab 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -33,6 +33,16 @@ impl<'a> EvalCtx<'a> { } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // Separated Position reads as the combination of X/Y/Z Position. + if prop.match_id == "position" + && let Some(tr) = layer.transform() + && let Some(px) = tr.get("positionX") + && tr.get("position").is_some_and(|p| p.uid == prop.uid) + { + let z = tr.get("positionZ").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); + let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); + return Value::Vec3([self.value(layer, px).as_f64(), y, z]); + } let lt = layer.layer_time(self.time); let v = prop.value_at(lt); if prop.has_expression() @@ -63,6 +73,21 @@ impl<'a> EvalCtx<'a> { pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool { self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false) } + /// Layer Position, honouring Separate Dimensions (X/Y/Z Position properties). + pub fn position(&self, layer: &Layer, tr: &PropGroup) -> [f64; 3] { + match tr.get("positionX") { + Some(px) => [self.value(layer, px).as_f64(), self.f(layer, tr, "positionY", 0.0), self.f(layer, tr, "positionZ", 0.0)], + None => self.v3(layer, tr, "position", [0.0; 3]), + } + } + /// Source time of a layer at the context time: Time Remap's value when enabled, else the + /// (stretch-aware) layer time. + pub fn source_time(&self, layer: &Layer) -> Tick { + if let Some(tr) = layer.props.get("timeRemap") { + return Tick::from_seconds_f64(self.value(layer, tr).as_f64()); + } + layer.layer_time(self.time) + } pub fn layer(&self, id: LayerId) -> Option<&'a Layer> { self.comp.layer(id) } @@ -71,7 +96,7 @@ impl<'a> EvalCtx<'a> { pub fn local_matrix(&self, layer: &Layer) -> Mat4 { let Some(tr) = layer.transform() else { return Mat4::IDENTITY }; let three = layer.is_3d(); - let pos = self.v3(layer, tr, "position", [0.0; 3]); + let pos = self.position(layer, tr); let rz = self.f(layer, tr, "rotation", 0.0); if layer.is_camera() || layer.is_light() { return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd3356..366264ecd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -166,7 +166,7 @@ impl<'a> Renderer<'a> { return None; } let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 }; - let lt = layer.layer_time(ctx.time); + let lt = ctx.source_time(layer); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -175,7 +175,7 @@ impl<'a> Renderer<'a> { if !f.has_video { return None; } - let lt = layer.layer_time(ctx.time); + let lt = ctx.source_time(layer); let img = self.footage.frame(*item, f, lt)?; let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. @@ -332,6 +332,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_remap; /// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size /// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer diff --git a/crates/render/src/tests_remap.rs b/crates/render/src/tests_remap.rs new file mode 100644 index 000000000..67b6ebef7 --- /dev/null +++ b/crates/render/src/tests_remap.rs @@ -0,0 +1,65 @@ +//! Time remapping and separated Position dimensions. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemKind, LayerSource, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn s(x: f64) -> Tick { + Tick::from_seconds_f64(x) +} + +#[test] +fn separated_position_drives_the_transform() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, s(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 20, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (20, 20), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut px = ids.prop("positionX", "X Position", Value::Scalar(20.0)); + px.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(20.0)), Keyframe::new(s(1.0), Value::Scalar(180.0))]; + let py = ids.prop("positionY", "Y Position", Value::Scalar(50.0)); + let pz = ids.prop("positionZ", "Z Position", Value::Scalar(0.0)); + p.next_id = next; + let tr = l.props.sub_mut("transform").unwrap(); + // The combined property is ignored while separated. + tr.get_mut("position").unwrap().value = Value::Vec3([100.0, 10.0, 0.0]); + tr.children.extend([Node::Prop(px), Node::Prop(py), Node::Prop(pz)]); + p.comp_mut(cid).unwrap().layers.push(l); + let a = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 10)[3] < 0.01); + let b = render_frame(&p, cid, s(0.5), 1.0); + assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01); +} + +#[test] +fn time_remap_picks_the_source_frame() { + let mut p = Project::default(); + let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + // Inner comp: a solid that moves from x=10 (t=0) to x=90 (t=2). + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + let mut next = p.next_id; + // Freeze on source time 2 s. + let tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(2.0)); + p.next_id = next; + let mut tr = tr; + tr.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)).hold()]; + pre.props.children.push(Node::Prop(tr)); + p.comp_mut(cid).unwrap().layers.push(pre); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.get(90, 50)[3] > 0.99, "frozen at the end position"); + assert!(img.get(10, 50)[3] < 0.01); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d27..1000474a7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,9 @@ pub enum Dialog { CompSettings, SolidSettings, CommandPalette, + KeyVelocity, + KeyInterpolation, + TimeStretch, } /// Host hooks provided by the native app (file pickers etc.). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 321cee162..154342466 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -245,6 +245,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "app.compSettings" | "comp.settings" if params.as_object().is_none_or(|m| m.is_empty()) => { crate::panels::dialogs::open_comp_settings(app)?; } + "keys.velocity" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_velocity(app)?, + "keys.interpolation" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_interpolation(app)?, + "layer.timeStretch" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_time_stretch(app)?, "app.solidSettings" | "layer.newSolid" if params.as_object().is_none_or(|m| m.is_empty()) => { crate::panels::dialogs::open_new_solid(app)?; } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 87a2a2cfd..d7511e5f2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::icons::{self, Icon}; use crate::theme::Tokens; -use crate::{Dialog, EffectcraftApp, widgets}; +use crate::{Dialog, EffectcraftApp}; #[derive(Clone, Debug)] pub struct CompDraft { @@ -34,6 +34,9 @@ pub struct DialogState { pub solid_size: [u32; 2], pub palette_query: String, pub palette_sel: usize, + pub velocity: super::key_dialogs::VelocityDraft, + pub interp: super::key_dialogs::InterpDraft, + pub stretch: super::key_dialogs::StretchDraft, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -72,7 +75,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { Ok(()) } -fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { +pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { // Dim the app. let screen = ctx.content_rect(); ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120)); @@ -95,6 +98,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings), Dialog::SolidSettings => solid(app, ctx, &t), Dialog::CommandPalette => palette(app, ctx, &t), + Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t), + Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), + Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), } } @@ -262,7 +268,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } -fn ui_enter(ctx: &egui::Context) -> bool { +pub(crate) fn ui_enter(ctx: &egui::Context) -> bool { !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs new file mode 100644 index 000000000..7a3072e61 --- /dev/null +++ b/crates/ui-egui/src/panels/graph.rs @@ -0,0 +1,452 @@ +//! The Graph Editor (Timeline ▸ Shift+F3): value graph and speed graph of the selected (or all +//! animated) properties, keyframes you can drag in time and value, Bezier/influence handles on +//! selected keys, auto-zoom / fit, and the bottom button bar (graph type, show selected, +//! auto-zoom, fit, separate dimensions, hold/linear/auto Bezier, Easy Ease family). +//! +//! Every edit is an engine command (`keys.set`, `keys.setEase`, `keys.select`, `keys.*`). + +use effectcraft_engine::KeyRef; +use effectcraft_engine::keyframe::{Interp, Value, side_ease}; +use effectcraft_engine::project::{Comp, Layer, LayerId, Property}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::timeline::TMap; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +type Actions = Vec<(String, serde_json::Value)>; + +const DIM_COLORS: [Color32; 4] = + [Color32::from_rgb(0xe0, 0x55, 0x55), Color32::from_rgb(0x5c, 0xc8, 0x5c), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)]; +const PROP_COLORS: [Color32; 6] = [ + Color32::from_rgb(0xe8, 0xc8, 0x40), + Color32::from_rgb(0xd0, 0x70, 0xe0), + Color32::from_rgb(0x40, 0xc8, 0xc8), + Color32::from_rgb(0xf0, 0x90, 0x40), + Color32::from_rgb(0x9c, 0xd8, 0x50), + Color32::from_rgb(0xf0, 0x70, 0x90), +]; + +struct Handle { + out: bool, + /// Comp seconds / graph value of the handle end. + t: f64, + v: f64, + /// Original speed (sign is kept when dragging in the speed graph). + speed: f64, + /// Segment duration (layer seconds) the influence is relative to. + dur: f64, +} + +struct KeyPt { + idx: usize, + time: Tick, + /// Comp seconds. + t: f64, + v: f64, + sel: bool, + handles: Vec, +} + +struct Curve<'a> { + layer: &'a Layer, + prop: &'a Property, + dim: usize, + color: Color32, + pts: Vec<(f64, f64)>, + keys: Vec, + /// Values editable in the value graph (not spatial / combined dimensions). + editable: bool, +} + +/// Properties shown: the selected ones, or every animated property of the selected layers. +fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> { + let st = &app.session.state; + let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates(); + if app.ui.timeline.graph_show_selected && !st.selected_props.is_empty() { + return st.selected_props.iter().filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.find(*u)).is_some_and(numeric)).copied().collect(); + } + let mut v = vec![]; + for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id)) { + l.props.walk("", &mut |_, pr| { + if (pr.is_animated() || pr.has_expression()) && numeric(pr) { + v.push((l.id, pr.uid)); + } + }); + } + v +} + +fn shown_dims(l: &Layer, pr: &Property) -> usize { + let n = pr.value.components().len(); + if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) } +} + +/// Build the curves for the visible time span. +fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec> { + let sel = &app.session.state.selected_keys; + let n = ((plot.width() / 2.0) as usize).max(2); + let mut out = vec![]; + for (pi, (lid, uid)) in shown_props(app, comp).into_iter().enumerate() { + let Some(l) = comp.layer(lid) else { continue }; + let Some(pr) = l.props.find(uid) else { continue }; + let spatial = pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)); + let dims = if speed && spatial { 1 } else { shown_dims(l, pr) }; + for d in 0..dims { + let color = if dims > 1 { DIM_COLORS[d % 4] } else { PROP_COLORS[pi % PROP_COLORS.len()] }; + let mut pts = Vec::with_capacity(n); + for i in 0..n { + let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32; + let ct = tm.t(x); + let tt = Tick::from_seconds_f64(ct); + let v = if speed { + let lt = l.layer_time(tt); + if spatial { + effectcraft_engine::keyframe::speed(&pr.keys, lt, true) + } else { + effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs() + } + } else { + ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0) + }; + pts.push((ct, v)); + } + let mut keys = vec![]; + for (i, k) in pr.keys.iter().enumerate() { + let ks = sel.contains(&KeyRef { layer: lid, prop: uid, time: k.time }); + let kt = l.comp_time(k.time).seconds(); + let ed = if spatial { 0 } else { d }; + let mut handles = vec![]; + let mut side_speeds = vec![]; + for out_side in [false, true] { + let Some(e) = side_ease(&pr.keys, i, ed, pr.spatial, out_side) else { continue }; + let j = if out_side { i + 1 } else { i - 1 }; + let dur = (pr.keys[j].time.seconds() - k.time.seconds()).abs(); + side_speeds.push(e.speed.abs()); + let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; + let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; + let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; + if ks && show && (bezier || speed) { + handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); + } + } + let v = if speed { + if side_speeds.is_empty() { 0.0 } else { side_speeds.iter().sum::() / side_speeds.len() as f64 } + } else { + k.value.components().get(d).copied().unwrap_or(0.0) + }; + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + } + out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); + } + } + out +} + +fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { + let t = app.tokens; + let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); + p.rect_filled( + r, + 3.0, + if on { + t.accent.gamma_multiply(0.55) + } else if resp.hovered() { + t.hover + } else { + Color32::from_rgb(0x2c, 0x2c, 0x2c) + }, + ); + p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + app.auto.add(&format!("timeline.graph.{id}"), r, tip); + *x += w + 4.0; + resp.clicked() +} + +/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits. +pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let footer_h = 28.0; + let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h)); + let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max); + // Background first so keys and handles (added later) sit on top for hit-testing. + let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag()); + p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d)); + app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone()); + let speed = app.ui.timeline.graph_mode == "speed"; + let cs = curves(app, comp, ectx, tm, plot, speed); + + // Vertical range: auto-zoom to the curves + handles, or the stored manual range. + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for c in &cs { + for (_, v) in &c.pts { + lo = lo.min(*v); + hi = hi.max(*v); + } + for k in &c.keys { + for v in std::iter::once(k.v).chain(k.handles.iter().map(|h| h.v)) { + lo = lo.min(v); + hi = hi.max(v); + } + } + } + if !lo.is_finite() { + (lo, hi) = (0.0, 100.0); + } + if speed { + lo = lo.min(0.0); + } + if (hi - lo).abs() < 1e-9 { + hi = lo + 1.0; + } + let pad = (hi - lo) * 0.08; + let auto = (lo - pad, hi + pad); + // While a key or handle is being dragged the scale stays put (no feedback loop). + let drag_id = egui::Id::new("graph-dragging"); + let dragging = ctx.data(|d| d.get_temp::(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down()); + let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) }; + if app.ui.timeline.graph_auto_zoom && !dragging { + app.ui.timeline.graph_range = Some((lo, hi)); + } + let ymap = |v: f64| plot.max.y - ((v - lo) / (hi - lo)) as f32 * plot.height(); + let vmap = |y: f32| lo + (plot.max.y - y) as f64 / plot.height() as f64 * (hi - lo); + + // Grid + value labels. + let pc = p.with_clip_rect(plot); + let step = nice_step((hi - lo) / 6.0); + let mut gv = (lo / step).ceil() * step; + while gv <= hi { + let y = ymap(gv); + pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(12))); + pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), t.text_faint); + gv += step; + } + if cs.is_empty() { + p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); + } + + // Curves. + for c in &cs { + let pts: Vec = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect(); + pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color))); + } + // Keys and handles. + let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; + let fr = comp.frame_rate; + for c in &cs { + let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); + for k in &c.keys { + let kp = pos2(tm.x(k.t), ymap(k.v)); + if !plot.expand(6.0).contains(kp) { + continue; + } + let kjson = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds()}); + key_screens.push((kjson.clone(), kp)); + // Handles (selected keys). + for h in &k.handles { + let hp = pos2(tm.x(h.t), ymap(h.v)); + pc.line_segment([kp, hp], Stroke::new(1.0, Color32::from_rgb(0xd8, 0xd8, 0x60))); + pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60)); + let side = if h.out { "out" } else { "in" }; + let hr = Rect::from_center_size(hp, vec2(10.0, 10.0)); + let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag()); + app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle"); + if hresp.dragged() + && let Some(pt) = hresp.interact_pointer_pos() + { + ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); + let mut dt = (tm.t(pt.x) - k.t) * if h.out { 1.0 } else { -1.0 }; + dt = dt.max(h.dur * 0.001); + let influence = (dt / h.dur.max(1e-9) * 100.0).clamp(0.1, 100.0); + let sp = if speed { + let mag = vmap(pt.y).max(0.0); + if h.speed < 0.0 { -mag } else { mag } + } else { + let dv = vmap(pt.y) - k.v; + dv / dt * if h.out { 1.0 } else { -1.0 } + }; + let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": format!("ghandle-{uid}-{}", k.idx)}); + if c.editable { + params["dim"] = json!(d); + } + actions.push(("keys.setEase".into(), params)); + } + if hresp.drag_stopped() { + ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); + actions.push(("__endMerge".into(), json!({}))); + } + } + // The key itself. + let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); + let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag()); + app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); + let shift = ui.input(|i| i.modifiers.shift); + if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift}))); + } + if kresp.dragged() + && let Some(pt) = kresp.interact_pointer_pos() + { + ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); + let merge = format!("gkey-{uid}-{}", k.idx); + let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0)))); + let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); + if !speed && c.editable { + let mut comps = c.prop.keys[k.idx].value.components(); + if d < comps.len() { + comps[d] = vmap(pt.y); + } + params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) }; + } + actions.push(("keys.set".into(), params)); + } + if kresp.drag_stopped() { + ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); + actions.push(("__endMerge".into(), json!({}))); + } + } + } + + // Background: click deselects keys, drag box-selects. + if bg.clicked() { + actions.push(("keys.select".into(), json!({"keys": []}))); + } + if bg.dragged() + && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos()) + { + let br = Rect::from_two_pos(o, cur); + pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); + pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br)); + } + if bg.drag_stopped() + && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::new("graph-box"))) + { + ctx.data_mut(|d| d.remove::(egui::Id::new("graph-box"))); + let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect(); + actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)}))); + } + // Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off). + if ui.rect_contains_pointer(plot) { + let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command)); + if zoom && dy.abs() > 0.0 { + let k = (-dy as f64 / 200.0).exp(); + let mid = (lo + hi) / 2.0; + app.ui.timeline.graph_auto_zoom = false; + app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k)); + } + } + + // Button bar. + p.rect_filled(bar, 0.0, t.panel_bg); + p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); + let y = bar.center().y; + let mut x = bar.min.x + 8.0; + let tl = &app.ui.timeline; + let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom); + if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") { + app.ui.timeline.graph_mode = "value".into(); + } + if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") { + app.ui.timeline.graph_mode = "speed".into(); + } + x += 6.0; + if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + app.ui.timeline.graph_show_selected = !show_sel; + } + if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + app.ui.timeline.graph_auto_zoom = !auto_on; + } + if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + app.ui.timeline.graph_auto_zoom = true; + app.ui.timeline.pps = None; + } + if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + fit_selection(app, &cs, tm, plot); + } + x += 6.0; + let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { + let layer = comp.layer(*l)?; + let tr = layer.transform()?; + (tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u))) + .then_some(l.0) + }); + if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) + { + actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); + } + x += 6.0; + for (label, cmd, params, tip) in [ + ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), + ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"}), "Convert selected keyframes to Auto Bezier"), + ("Easy Ease", "keys.easyEase", json!({}), "Easy Ease (F9)"), + ("Ease In", "keys.easyEaseIn", json!({}), "Easy Ease In (Shift+F9)"), + ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), + ] { + let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); + if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + actions.push((cmd.into(), params)); + } + } +} + +/// Zoom time and value to the selected keys (and their handles). +fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { + let mut t0 = f64::INFINITY; + let mut t1 = f64::NEG_INFINITY; + let mut v0 = f64::INFINITY; + let mut v1 = f64::NEG_INFINITY; + for c in cs { + for k in c.keys.iter().filter(|k| k.sel) { + for (t, v) in std::iter::once((k.t, k.v)).chain(k.handles.iter().map(|h| (h.t, h.v))) { + t0 = t0.min(t); + t1 = t1.max(t); + v0 = v0.min(v); + v1 = v1.max(v); + } + } + } + if !t0.is_finite() { + return; + } + let span = (t1 - t0).max(0.2); + let pps = (plot.width() as f64 * 0.8 / span).clamp(5.0, 4000.0); + app.ui.timeline.pps = Some(pps); + app.ui.timeline.start = (t0 - span * 0.1).max(0.0); + let pad = ((v1 - v0) * 0.15).max(1.0); + app.ui.timeline.graph_auto_zoom = false; + app.ui.timeline.graph_range = Some((v0 - pad, v1 + pad)); + let _ = tm; +} + +fn nice_step(raw: f64) -> f64 { + let raw = raw.abs().max(1e-9); + let mag = 10f64.powf(raw.log10().floor()); + let n = raw / mag; + mag * if n < 1.5 { + 1.0 + } else if n < 3.5 { + 2.0 + } else if n < 7.5 { + 5.0 + } else { + 10.0 + } +} + +fn fmt(v: f64, step: f64) -> String { + let dec = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize }; + let s = format!("{v:.dec$}"); + if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } +} diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs new file mode 100644 index 000000000..52190f0dd --- /dev/null +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -0,0 +1,287 @@ +//! Animation dialogs: Keyframe Velocity, Keyframe Interpolation and Time Stretch. Each one is +//! pre-filled from the engine (`keys.info`, the layer) and commits through one engine command. + +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Debug, Default)] +pub struct VelocityDraft { + pub dims: usize, + pub units: String, + pub name: String, + pub in_speed: Vec, + pub in_inf: Vec, + pub out_speed: Vec, + pub out_inf: Vec, + /// Which sides exist (first key has no incoming side, last none outgoing). + pub has_in: bool, + pub has_out: bool, + pub continuous: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct InterpDraft { + /// Index into TEMPORAL / SPATIAL / ROVING (0 = Current Settings). + pub temporal: usize, + pub spatial: usize, + pub roving: usize, + pub has_spatial: bool, + pub count: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct StretchDraft { + pub percent: f64, + pub duration: f64, + /// 0 = Layer In-point, 1 = Current Frame, 2 = Layer Out-point. + pub hold: usize, + pub orig_duration: f64, + pub orig_percent: f64, +} + +pub const TEMPORAL: [(&str, &str); 6] = [ + ("Current Settings", ""), + ("Linear", "linear"), + ("Bezier", "bezier"), + ("Continuous Bezier", "continuousBezier"), + ("Auto Bezier", "autoBezier"), + ("Hold", "hold"), +]; +pub const SPATIAL: [(&str, &str); 5] = + [("Current Settings", ""), ("Linear", "linear"), ("Bezier", "bezier"), ("Continuous Bezier", "continuousBezier"), ("Auto Bezier", "autoBezier")]; +pub const ROVING: [&str; 3] = ["Current Settings", "Lock To Time", "Rove Across Time"]; + +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or(0.0) +} + +/// Animation ▸ Keyframe Velocity… (⇧⌘K) with keys selected. +pub fn open_velocity(app: &mut EffectcraftApp) -> Result<(), String> { + let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?; + let first = info.as_array().and_then(|a| a.first()).ok_or("select keyframes first")?; + let dims = first["dims"].as_u64().unwrap_or(1).max(1) as usize; + let side = |k: &str| -> (Vec, Vec, bool) { + let a = first[k].as_array().cloned().unwrap_or_default(); + let has = a.iter().any(|e| !e.is_null()); + let sp = (0..dims).map(|d| a.get(d).map(|e| num(&e["speed"])).unwrap_or(0.0)).collect(); + let inf = (0..dims).map(|d| a.get(d).map(|e| num(&e["influence"])).filter(|v| *v > 0.0).unwrap_or(33.33)).collect(); + (sp, inf, has) + }; + let (in_speed, in_inf, has_in) = side("inEase"); + let (out_speed, out_inf, has_out) = side("outEase"); + app.dialog_state.velocity = VelocityDraft { + dims, + units: first["units"].as_str().unwrap_or("units/sec").into(), + name: first["name"].as_str().unwrap_or("").into(), + in_speed, + in_inf, + out_speed, + out_inf, + has_in, + has_out, + continuous: first["continuous"].as_bool().unwrap_or(false), + }; + app.dialog = Some(Dialog::KeyVelocity); + Ok(()) +} + +/// Animation ▸ Keyframe Interpolation… (⌥⌘K). +pub fn open_interpolation(app: &mut EffectcraftApp) -> Result<(), String> { + let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?; + let a = info.as_array().cloned().unwrap_or_default(); + if a.is_empty() { + return Err("select keyframes first".into()); + } + app.dialog_state.interp = InterpDraft { has_spatial: a.iter().any(|k| !k["spatial"].is_null()), count: a.len(), ..Default::default() }; + app.dialog = Some(Dialog::KeyInterpolation); + Ok(()) +} + +/// Layer ▸ Time ▸ Time Stretch… +pub fn open_time_stretch(app: &mut EffectcraftApp) -> Result<(), String> { + let lid = *app.session.state.selected_layers.first().ok_or("select a layer first")?; + let l = app.session.active_comp().and_then(|c| c.layer(lid)).ok_or("no layer")?; + let dur = (l.out_point - l.in_point).seconds(); + app.dialog_state.stretch = StretchDraft { percent: l.stretch, duration: dur, hold: 0, orig_duration: dur, orig_percent: l.stretch }; + app.dialog = Some(Dialog::TimeStretch); + Ok(()) +} + +fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str) -> (bool, bool) { + let (mut ok, mut cancel) = (false, false); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add(&format!("{prefix}.ok"), r.rect, "OK"); + ok = r.clicked(); + let r = ui.button("Cancel"); + app.auto.add(&format!("{prefix}.cancel"), r.rect, "Cancel"); + cancel = r.clicked(); + }); + (ok, cancel) +} + +fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { + let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + app.auto.add(id, r.rect, id); +} + +pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.velocity.clone(); + let (mut ok, mut cancel) = (false, false); + let title = if d.name.is_empty() { "Keyframe Velocity".to_string() } else { format!("Keyframe Velocity: {}", d.name) }; + let h = 250.0 + 2.0 * 26.0 * d.dims as f32; + super::dialogs::modal(ctx, &title, vec2(440.0, h), t, |ui| { + for (label, out) in [("Incoming Velocity", false), ("Outgoing Velocity", true)] { + ui.label(egui::RichText::new(label).font(Tokens::medium(12.5))); + let enabled = if out { d.has_out } else { d.has_in }; + egui::Grid::new(("vel", out)).num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| { + for dim in 0..d.dims { + let tag = if d.dims > 1 { format!("Dimension {}", ["X", "Y", "Z", "W"][dim.min(3)]) } else { String::new() }; + ui.label(egui::RichText::new(tag).color(t.text_dim)); + let side = if out { "out" } else { "in" }; + ui.horizontal(|ui| { + ui.label("Speed:"); + let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] }; + drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled); + ui.label(egui::RichText::new(&d.units).color(t.text_dim)); + }); + ui.horizontal(|ui| { + ui.label("Influence:"); + let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] }; + drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled); + }); + ui.end_row(); + } + }); + ui.add_space(8.0); + } + let r = ui.checkbox(&mut d.continuous, "Continuous (Lock Outgoing to Incoming)"); + app.auto.add("dialog.velocity.continuous", r.rect, "Continuous"); + if d.continuous { + d.out_speed = d.in_speed.clone(); + } + (ok, cancel) = buttons(ui, app, t, "dialog.velocity"); + }); + app.dialog_state.velocity = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut params = json!({"continuous": d.continuous}); + if d.has_in { + params["inSpeed"] = json!(d.in_speed); + params["inInfluence"] = json!(d.in_inf); + } + if d.has_out { + params["outSpeed"] = json!(d.out_speed); + params["outInfluence"] = json!(d.out_inf); + } + if let Err(e) = app.session.execute("keys.velocity", params) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) { + ui.add_enabled_ui(enabled, |ui| { + let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(opts[*sel]).show_ui(ui, |ui| { + for (i, o) in opts.iter().enumerate() { + ui.selectable_value(sel, i, *o); + } + }); + app.auto.add(id, r.response.rect, id); + }); +} + +pub fn interpolation(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.interp.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Keyframe Interpolation", vec2(440.0, 250.0), t, |ui| { + egui::Grid::new("interp-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { + ui.label("Temporal Interpolation:"); + combo(ui, app, "dialog.interpolation.temporal", &mut d.temporal, &TEMPORAL.map(|x| x.0), true); + ui.end_row(); + ui.label("Spatial Interpolation:"); + combo(ui, app, "dialog.interpolation.spatial", &mut d.spatial, &SPATIAL.map(|x| x.0), d.has_spatial); + ui.end_row(); + ui.label("Roving:"); + combo(ui, app, "dialog.interpolation.roving", &mut d.roving, &ROVING, d.has_spatial); + ui.end_row(); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new(format!("{} keyframe{} selected", d.count, if d.count == 1 { "" } else { "s" })).color(t.text_dim)); + (ok, cancel) = buttons(ui, app, t, "dialog.interpolation"); + }); + app.dialog_state.interp = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut params = json!({}); + if d.temporal > 0 { + params["interpolation"] = json!(TEMPORAL[d.temporal].1); + } + if d.spatial > 0 && d.has_spatial { + params["spatial"] = json!(SPATIAL[d.spatial].1); + } + if d.roving > 0 && d.has_spatial { + params["roving"] = json!(d.roving == 2); + } + if params.as_object().is_some_and(|m| !m.is_empty()) + && let Err(e) = app.session.execute("keys.interpolation", params) + { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.stretch.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Time Stretch", vec2(420.0, 300.0), t, |ui| { + ui.label(egui::RichText::new("Stretch").font(Tokens::medium(12.5))); + egui::Grid::new("stretch-grid").num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { + ui.label("Original Duration:"); + ui.label(format!("{:.2} s", d.orig_duration)); + ui.end_row(); + ui.label("Stretch Factor:"); + let before = d.percent; + drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true); + if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } + ui.end_row(); + ui.label("New Duration:"); + let before = d.duration; + drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true); + if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { + d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + } + ui.end_row(); + }); + ui.add_space(10.0); + ui.label(egui::RichText::new("Hold In Place").font(Tokens::medium(12.5))); + for (i, l) in ["Layer In-point", "Current Frame", "Layer Out-point"].into_iter().enumerate() { + let r = ui.radio_value(&mut d.hold, i, l); + app.auto.add(&format!("dialog.timeStretch.hold.{i}"), r.rect, l); + } + (ok, cancel) = buttons(ui, app, t, "dialog.timeStretch"); + }); + app.dialog_state.stretch = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let hold = ["in", "current", "out"][d.hold.min(2)]; + if let Err(e) = app.session.execute("layer.timeStretch", json!({"percent": d.percent, "hold": hold})) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848b..a1998dc3f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -3,7 +3,9 @@ pub mod dialogs; pub mod effect_controls; pub mod effects_presets; +pub mod graph; pub mod info; +pub mod key_dialogs; pub mod misc; pub mod project; pub mod text_panels; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index bba588ad4..939eaed0d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -167,10 +167,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.double_clicked() { match &it.kind { ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))), - ItemKind::Folder => { - if !app.ui.project_open_folders.remove(&id.0) { - app.ui.project_open_folders.insert(id.0); - } + ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { + app.ui.project_open_folders.insert(id.0); } _ => {} } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 53b0cdc8d..121f7f1f6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -42,7 +42,6 @@ struct Cols { label: f32, num: f32, name: f32, - name_w: f32, switches: f32, mode: f32, trkmat: f32, @@ -61,22 +60,22 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols { let mode = switches + SW * 8.0 + 6.0; let trkmat = mode + if show_modes { 92.0 } else { 0.0 }; let parent = trkmat + if show_modes { 112.0 } else { 0.0 }; - Cols { av, label, num, name, name_w, switches, mode, trkmat, parent, end: parent + 116.0 } + Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 } } /// Timeline horizontal mapping. #[derive(Clone, Copy, Debug)] -struct TMap { - x0: f32, - start: f64, - pps: f64, +pub(crate) struct TMap { + pub x0: f32, + pub start: f64, + pub pps: f64, } impl TMap { - fn x(&self, secs: f64) -> f32 { + pub fn x(&self, secs: f64) -> f32 { self.x0 + ((secs - self.start) * self.pps) as f32 } - fn t(&self, x: f32) -> f64 { + pub fn t(&self, x: f32) -> f64 { self.start + (x - self.x0) as f64 / self.pps } } @@ -141,6 +140,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { if p.two_d_only && layer.is_3d() { return false; } + // Separate Dimensions: X/Y/Z Position replace Position. + if p.match_id == "position" && layer.transform().is_some_and(|tr| tr.get("positionX").is_some() && tr.get("position").is_some_and(|q| q.uid == p.uid)) { + return false; + } true } @@ -168,7 +171,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { } if let Some(kind) = tl.reveal.first() { let (group, props): (&str, Vec<&str>) = match kind.as_str() { - "position" => ("transform", vec!["position"]), + "position" => ("transform", vec!["position", "positionX", "positionY", "positionZ"]), "scale" => ("transform", vec!["scale"]), "rotation" => ("transform", vec!["rotation", "rotationX", "rotationY", "orientation"]), "opacity" => ("transform", vec!["opacity"]), @@ -439,7 +442,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let fd = comp.frame_duration().seconds(); let cy0 = ruler.min.y + 11.0; let mut run: Option<(i64, i64)> = None; - let mut draw_run = |a: i64, b: i64| { + let draw_run = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_green); @@ -785,10 +788,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - if body.clicked() || body.drag_started() { - if !is_sel { - actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); - } + if (body.clicked() || body.drag_started()) && !is_sel { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); } layer_context_menu(&body, layer, &mut actions); let drag_key = format!("bar-{}", layer.id.0); @@ -1001,15 +1002,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } kresp.context_menu(|ui| { for (lbl, cmd, params) in [ + ("Copy", "keys.copy", json!({})), + ("Paste", "keys.paste", json!({})), + ("-", "", json!({})), + ("Keyframe Interpolation…", "keys.interpolation", json!({})), + ("Keyframe Velocity…", "keys.velocity", json!({})), + ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), + ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), ("Easy Ease In", "keys.easyEaseIn", json!({})), ("Easy Ease Out", "keys.easyEaseOut", json!({})), - ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Time-Reverse Keyframes", "keys.timeReverse", json!({})), + ("-", "", json!({})), ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), - ("Auto Bezier", "keys.interpolation", json!({"autoBezier": true})), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), + ("Select All Keyframes", "keys.selectAll", json!({})), ("Delete", "keys.delete", json!({})), ] { + if lbl == "-" { + ui.separator(); + continue; + } + if lbl == "Rove Across Time" && !prop.spatial { + continue; + } if ui.button(lbl).clicked() { if !ks { actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); @@ -1041,15 +1059,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Graph editor. if graph_on { - graph_editor(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } // Empty-area click in the graph: deselect keys; drag: box-select keys. - let empty = ui.interact(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); + let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; + let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); } if let (true, Some(origin), Some(cur)) = - (empty.dragged(), empty.interact_pointer_pos().map(|_| ctx.input(|i| i.pointer.press_origin())).flatten(), empty.interact_pointer_pos()) + (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) { let br = Rect::from_two_pos(origin, cur); gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); @@ -1188,6 +1207,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } for (id, params) in actions { + if id == "__endMerge" { + app.session.history.merge_key = None; + continue; + } if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; } @@ -1257,7 +1280,11 @@ fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(S item(ui, "Flip Vertical", "layer.transform", json!({"layers": [id], "op": "flipV"})); }); ui.menu_button("Time", |ui| { - item(ui, "Time-Reverse Layer", "layer.timeStretch", json!({"layers": [id], "op": "reverse"})); + item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": [id]})); + item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": [id]})); + item(ui, "Time Stretch…", "layer.timeStretch", json!({})); + item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": [id]})); + item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": [id]})); }); ui.menu_button("Blending Mode", |ui| { for m in BlendMode::ALL { @@ -1412,86 +1439,3 @@ fn value_editor( } } } - -/// Value graph of the selected properties (numeric dimensions over time) with key markers. -fn graph_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect) { - let t = app.tokens; - let props: Vec<(LayerId, u64)> = if app.session.state.selected_props.is_empty() { - // Default: animated properties of selected layers. - let mut v = vec![]; - for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) { - l.props.walk("", &mut |_, pr| { - if pr.is_animated() && !pr.value.components().is_empty() { - v.push((l.id, pr.uid)); - } - }); - } - v - } else { - app.session.state.selected_props.clone() - }; - p.rect_filled(area, 0.0, Color32::from_rgb(0x1e, 0x1e, 0x1e)); - // Grid. - for i in 1..8 { - let y = area.min.y + area.height() * i as f32 / 8.0; - p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(10))); - } - if props.is_empty() { - p.text(area.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); - return; - } - let colors = - [Color32::from_rgb(0xe0, 0x50, 0x50), Color32::from_rgb(0x60, 0xd0, 0x60), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)]; - // Sample all curves over the visible span; normalise to a common range. - let n = ((area.width() / 2.0) as usize).max(2); - let mut curves: Vec<(Vec>, &Layer, &Property)> = vec![]; - let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); - for (lid, uid) in &props { - let Some(l) = comp.layer(*lid) else { continue }; - let Some(pr) = l.props.find(*uid) else { continue }; - let mut dims: Vec> = vec![]; - for i in 0..n { - let x = area.min.x + area.width() * i as f32 / (n - 1) as f32; - let tt = Tick::from_seconds_f64(tm.t(x)); - let v = ectx.at(tt).value(l, pr).components(); - if dims.is_empty() { - dims = vec![Vec::with_capacity(n); v.len().min(3)]; - } - for (d, c) in v.iter().take(dims.len()).enumerate() { - dims[d].push(*c); - lo = lo.min(*c); - hi = hi.max(*c); - } - } - curves.push((dims, l, pr)); - } - if !lo.is_finite() || !hi.is_finite() { - return; - } - if (hi - lo).abs() < 1e-9 { - hi = lo + 1.0; - } - let pad = (hi - lo) * 0.1; - let (lo, hi) = (lo - pad, hi + pad); - let ymap = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height(); - for (dims, l, pr) in &curves { - for (d, vals) in dims.iter().enumerate() { - let pts: Vec = vals.iter().enumerate().map(|(i, v)| pos2(area.min.x + area.width() * i as f32 / (n - 1) as f32, ymap(*v))).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, colors[d % colors.len()]))); - } - for k in &pr.keys { - let x = tm.x(l.comp_time(k.time).seconds()); - for (d, v) in k.value.components().iter().take(dims.len()).enumerate() { - let c = pos2(x, ymap(*v)); - p.rect_filled(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Color32::WHITE); - p.rect_stroke(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, colors[d % colors.len()]), StrokeKind::Outside); - } - } - } - // Value labels. - for i in 0..=4 { - let v = lo + (hi - lo) * i as f64 / 4.0; - p.text(pos2(area.min.x + 4.0, ymap(v)), Align2::LEFT_BOTTOM, format!("{v:.1}"), Tokens::ui(10.0), t.text_faint); - } - let _ = ui; -} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b1ae92af1..7c4dc7cc7 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -56,7 +56,7 @@ pub fn last_fit(ctx: &egui::Context) -> f32 { #[derive(Clone, Debug)] enum Gesture { Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] }, - Scale { layer: LayerId, anchor_screen: Pos2, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, uniform: bool }, + Scale { layer: LayerId, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3 }, Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 }, Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 }, Pan { start_pan: [f32; 2] }, @@ -436,17 +436,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (l2c, _) = ectx.layer_to_comp(&layer); let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); - let ac = l2c.apply(gv2(a[0], a[1])); let lp = inv.apply(gv2(cpt[0], cpt[1])); let _ = hi; - Some(Gesture::Scale { - layer: lid, - anchor_screen: map.to_screen([ac.x, ac.y]), - start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), - start_local: [lp.x - a[0], lp.y - a[1]], - inv, - uniform: hi < 4, - }) + Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { let layers: Vec<(LayerId, [f64; 3], Mat3)> = app @@ -491,14 +483,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, inv, uniform, .. } => { + Gesture::Scale { layer, start_scale, start_local, inv, .. } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); // Local point relative to the anchor in *unscaled* layer space. let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]); let cur = [lp.x - a[0], lp.y - a[1]]; let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 }; let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 }; - let (fx, fy) = if mods.shift || uniform && start_local[0].abs() > 1e-6 && start_local[1].abs() > 1e-6 && mods.shift { + let (fx, fy) = if mods.shift { let f = (fx + fy) / 2.0; (f, f) } else { @@ -847,7 +839,7 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, app.ui.viewer.res = Resolution::ALL[i]; } x = r.max.x + 6.0; - if tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest") {} + let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest"); if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") { app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 05a85121b..1eadec4dc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -249,6 +249,28 @@ pub struct TimelineState { pub open_groups: BTreeSet, /// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal. pub reveal: Vec, + /// Graph Editor: `value` or `speed` graph. + #[serde(default = "value_graph")] + pub graph_mode: String, + /// Show only the selected properties (else every animated property of the selected layers). + #[serde(default = "yes")] + pub graph_show_selected: bool, + /// Auto-zoom the graph height to the visible curves. + #[serde(default = "yes")] + pub graph_auto_zoom: bool, + /// Manual graph value range (when auto-zoom is off). + #[serde(default)] + pub graph_range: Option<(f64, f64)>, + /// Properties whose inline expression editor is collapsed. + #[serde(default)] + pub expr_closed: BTreeSet, +} + +fn value_graph() -> String { + "value".into() +} +fn yes() -> bool { + true } impl Default for TimelineState { @@ -264,6 +286,11 @@ impl Default for TimelineState { open_layers: BTreeSet::new(), open_groups: BTreeSet::new(), reveal: vec![], + graph_mode: value_graph(), + graph_show_selected: true, + graph_auto_zoom: true, + graph_range: None, + expr_closed: BTreeSet::new(), } } } From 1cba07bdfe8ba5c6f084acd09026d310455f3fc5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:48:42 -0400 Subject: [PATCH 10/32] M5.1: AE timecode/ruler display parity - Current time shown as AE does: 0:00:02:15 (hours unpadded; time::format_timecode_ae), frame count zero-padded to 5 digits (00075 (29.97 fps)). - Time ruler labels in seconds:frames (00:15f, 01:00f) with frame-based adaptive spacing. - time.set snaps seconds to the nearest frame (2.5 s at 29.97 -> frame 75). - Mode / Track Matte columns hidden until Toggle Switches/Modes (F4), as in AE's default. - Timeline outline pane sized to its columns and clipped so Parent & Link never draws over the time graph in narrow timelines. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests_timeline.rs | 11 ++++++++++ crates/time/src/lib.rs | 10 +++++++++ crates/ui-egui/src/panels/mod.rs | 7 ++----- crates/ui-egui/src/panels/timeline.rs | 29 +++++++++++++++++++++++---- crates/ui-egui/src/state.rs | 2 +- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9644f5e65..0326a554a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -298,7 +298,7 @@ impl Session { if let Some(c) = self.state.active_comp { let comp = self.project.comp(c); let t = match comp { - Some(comp) => comp.frame_rate.snap(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), + Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), None => t, }; self.state.times.insert(c, t); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 315082de1..44f8043e3 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -240,6 +240,17 @@ fn time_reverse_keyframes() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0))); } +#[test] +fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 100, "height": 100, "frameRate": 29.97, "duration": 10})).unwrap(); + let r = s.execute("time.set", json!({"time": 2.5})).unwrap(); + assert_eq!(r["frame"], 75); + let fr = s.active_comp().unwrap().frame_rate; + assert_eq!(effectcraft_time::format_timecode_ae(75, fr, false), "0:00:02:15"); + assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 6e759c1ef..c30774906 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -330,6 +330,16 @@ pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) } +/// Format a frame count the way After Effects displays the current time: hours not padded +/// (`0:00:02:15`, or `0;00;02;15` for drop-frame). +pub fn format_timecode_ae(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + /// Format a tick according to a display mode. pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { let frame = rate.frame_at(t); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a1998dc3f..eaaaefa38 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -34,11 +34,8 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { let fr = comp.frame_rate; match session.project.settings.time_display { TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + session.project.settings.frame_start), - TimeDisplayStyle::Timecode => { - let df = fr.supports_drop_frame(); - let s = effectcraft_engine::time::format_timecode_frames(fr.frame_at(t + comp.display_start), fr, df); - if df { s } else { s.replace(';', ":") } - } + // AE's display: `0:00:02:15` (non-drop-frame counting, as AE shows new comps). + TimeDisplayStyle::Timecode => effectcraft_engine::time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false), } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 121f7f1f6..4cc8d3491 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -325,7 +325,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ruler_h = 34.0; let colhdr_h = 22.0; let footer_h = 24.0; - let left_w = (rect.width() * 0.5).clamp(420.0, 860.0); + let fixed = AV_W + LABEL_W + NUM_W + SW * 8.0 + if app.ui.timeline.show_modes { 92.0 + 112.0 } else { 0.0 } + 116.0 + 6.0; + let left_w = (fixed + 190.0).clamp(420.0, (rect.width() * 0.62).max(420.0)); let cw = cols(rect.min.x, left_w, app.ui.timeline.show_modes); let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; @@ -349,7 +350,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(20.0), t.timecode); - let sub = format!("{} ({:.2} fps)", fr.frame_at(time), fr.as_f64()); + let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64()); p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint); app.auto.add("timeline.timecode", tc_rect, &tc); if tc_resp.dragged() { @@ -461,14 +462,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { draw_run(a, b); } // Ticks + labels. - let secs_per_label = [0.0333, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0].into_iter().find(|s| s * pps >= 70.0).unwrap_or(60.0); + // Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels). + let fps_i = fr.as_f64().round().max(1.0) as i64; + let half = (fps_i / 2).max(1); + let label_frames = [1, 2, 5, 10, half, fps_i, 2 * fps_i, 5 * fps_i, 10 * fps_i, 30 * fps_i, 60 * fps_i] + .into_iter() + .find(|f| *f as f64 * fd * pps >= 52.0) + .unwrap_or(60 * fps_i); + let secs_per_label = label_frames as f64 * fd; let first = (tm.start / secs_per_label).floor() * secs_per_label; let mut s = first; let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), ruler.max)); while s <= tm.t(graph_x1) + secs_per_label { let x = tm.x(s); pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); - let label = if secs_per_label < 1.0 { format!("{:02}f", fr.frame_at(Tick::from_seconds_f64(s + 1e-9))) } else { format!("{:02}s", s.round() as i64) }; + let fno = (s / fd).round() as i64; + let label = if fno >= 60 * fps_i * 60 { + format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i) + } else { + format!("{:02}:{:02}f", fno / fps_i, fno % fps_i) + }; pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); for k in 1..5 { let xx = tm.x(s + secs_per_label * k as f64 / 5.0); @@ -551,7 +564,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut ui_actions: Vec = Vec::new(); let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0); let graph_on = app.ui.timeline.graph_editor; + let full_clip = ui.clip_rect(); + let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y))); + let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); for (ri, row) in rows.iter().enumerate() { + // Outline widgets never spill into the time graph (narrow timelines). + ui.set_clip_rect(left_clip); let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), rh)); y += rh; if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y { @@ -758,6 +776,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); } // Layer bar. + ui.set_clip_rect(right_clip); if !graph_on { let x_in = tm.x(layer.in_point.seconds()); let x_out = tm.x(layer.out_point.seconds()); @@ -962,6 +981,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + ui.set_clip_rect(right_clip); // Expression text row hint. if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled) { gp.text( @@ -1057,6 +1077,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + ui.set_clip_rect(full_clip); // Graph editor. if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1eadec4dc..5f76141a6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -280,7 +280,7 @@ impl Default for TimelineState { pps: None, scroll_y: 0.0, columns_w: 560.0, - show_modes: true, + show_modes: false, graph_editor: false, search: String::new(), open_layers: BTreeSet::new(), From 7f43d537eddff66fe045448fecfd5c70cd7c73f8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:49:20 -0400 Subject: [PATCH 11/32] M14.1: ship .mcp.json (headless + bridge servers) Co-Authored-By: Claude Opus 5.5 --- .mcp.json | 12 ++++++++++++ docs/agents.md | 5 +++++ 2 files changed, 17 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..caff41c9b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "effectcraft": { + "command": "cargo", + "args": ["run", "--quiet", "--release", "-p", "effectcraft-cli", "--", "mcp", "--demo"] + }, + "effectcraft-app": { + "command": "cargo", + "args": ["run", "--quiet", "--release", "-p", "effectcraft-cli", "--", "mcp", "--bridge", "9877"] + } + } +} diff --git a/docs/agents.md b/docs/agents.md index ea4abf69b..54a5ff38d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,6 +26,11 @@ Code, add a `.mcp.json` at the project root: } ``` +This repository ships a ready-made [`.mcp.json`](../.mcp.json): `effectcraft` (headless, demo +project loaded) and `effectcraft-app` (bridged to a desktop app started with `--control 9877`). Both +run through `cargo run --release`, so the first start compiles; run +`cargo build --release -p effectcraft-cli` once beforehand to avoid an MCP startup timeout. + You can also register it from the command line: `claude mcp add effectcraft -- /path/to/target/release/effectcraft-cli mcp`. Other clients (Claude Desktop, Cursor and the like) take the same `command` and `args`. From 9d7062362492a4534c29266e52aaa7143a59f894 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:57:08 -0400 Subject: [PATCH 12/32] M3.1: AE Composition Settings dialog (Basic/Advanced/3D Renderer, AE presets, pixel aspect, start timecode, resize anchor) engine comp.settings/comp.new: anchor (shifts unparented layers and their position keys on resize), startTime/startTimecode, renderer. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 42 ++- crates/engine/src/tests.rs | 24 ++ crates/ui-egui/src/panels/comp_settings.rs | 357 +++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 123 ++----- crates/ui-egui/src/panels/mod.rs | 1 + 5 files changed, 445 insertions(+), 102 deletions(-) create mode 100644 crates/ui-egui/src/panels/comp_settings.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index bbccd5d64..8b30025b3 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -13,12 +13,50 @@ pub(crate) fn rate_p(p: &Value) -> Option { } fn apply_settings(c: &mut Comp, p: &Value) { + let (ow, oh) = (c.width, c.height); if let Some(w) = p.get("width").and_then(Value::as_u64) { c.width = (w as u32).clamp(4, 30000); } if let Some(h) = p.get("height").and_then(Value::as_u64) { c.height = (h as u32).clamp(4, 30000); } + // Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center … + // 8 = bottom right). Unparented layers (and all their position keys) move by that offset. + if (c.width, c.height) != (ow, oh) { + let a = p.get("anchor").and_then(Value::as_u64).unwrap_or(4).min(8); + let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; + let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; + if dx != 0.0 || dy != 0.0 { + for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + let Some(pr) = l.props.prop_mut("transform/position") else { continue }; + let shift = |v: &mut effectcraft_keyframe::Value| { + if let effectcraft_keyframe::Value::Vec2(x) = v { + x[0] += dx; + x[1] += dy; + } else if let effectcraft_keyframe::Value::Vec3(x) = v { + x[0] += dx; + x[1] += dy; + } + }; + shift(&mut pr.value); + for k in &mut pr.keys { + shift(&mut k.value); + } + } + } + } + if let Some(t) = f_p(p, "startTime") { + c.display_start = Tick::from_seconds_f64(t); + } else if let Some(tc) = str_p(p, "startTimecode") + && let Ok(f) = effectcraft_time::parse_timecode(tc, c.frame_rate, c.frame_rate.supports_drop_frame(), 0) + { + c.display_start = c.frame_rate.tick_of(f); + } + match str_p(p, "renderer").map(str::to_ascii_lowercase).as_deref() { + Some("classic3d" | "classic") => c.renderer = effectcraft_project::Renderer::Classic3D, + Some("advanced3d" | "advanced") => c.renderer = effectcraft_project::Renderer::Advanced3D, + _ => {} + } if let Some(r) = rate_p(p) { c.frame_rate = r; } @@ -205,7 +243,7 @@ pub fn specs() -> Vec { "New Composition…", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, pixelAspect?, open?}", + "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}", always, new_comp ), @@ -214,7 +252,7 @@ pub fn specs() -> Vec { "Composition Settings…", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?}", + "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}", has_comp, settings ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2fdff890a..f4d9adc21 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -242,3 +242,27 @@ fn layer_tree_paths_resolve() { } assert!(checked > 20, "{checked}"); } + +#[test] +fn comp_settings_anchor_start_timecode_renderer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 100, "height": 100, "duration": 2.0})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [10, 20]})).unwrap(); + let pos = |s: &Session| { + let pr = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone(); + pr.keys[0].value.components() + }; + // Center anchor: grow by 100x50 → shift by half. + s.execute("comp.settings", json!({"width": 200, "height": 150})).unwrap(); + assert_eq!(pos(&s)[..2], [60.0, 45.0]); + // Top-left anchor: no shift. Bottom-right: full shift. + s.execute("comp.settings", json!({"width": 300, "anchor": 0})).unwrap(); + assert_eq!(pos(&s)[..2], [60.0, 45.0]); + s.execute("comp.settings", json!({"width": 200, "height": 100, "anchor": 8})).unwrap(); + assert_eq!(pos(&s)[..2], [-40.0, -5.0]); + s.execute("comp.settings", json!({"startTimecode": "0:00:01:00", "renderer": "advanced3D"})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.display_start, c.frame_rate.tick_of(30)); + assert_eq!(c.renderer, crate::project::Renderer::Advanced3D); +} diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs new file mode 100644 index 000000000..d46b36124 --- /dev/null +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -0,0 +1,357 @@ +//! Composition Settings dialog (also New Composition), laid out like After Effects': name, then +//! Basic / Advanced / 3D Renderer tabs, Preview checkbox, Cancel / OK. + +use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode}; +use egui::{Color32, vec2}; +use serde_json::json; + +use crate::theme::Tokens; + +/// AE's composition presets: (name, width, height, pixel aspect, fps). "Custom" is implicit. +pub const PRESETS: &[(&str, u32, u32, f64, f64)] = &[ + ("Social Media Portrait HD · 1080x1920 · 30 fps", 1080, 1920, 1.0, 30.0), + ("Social Media Landscape HD · 1920x1080 · 30 fps", 1920, 1080, 1.0, 30.0), + ("Social Media Square · 1080x1080 · 30 fps", 1080, 1080, 1.0, 30.0), + ("Social Media Portrait · 1080x1350 · 30 fps", 1080, 1350, 1.0, 30.0), + ("NTSC DV", 720, 480, 0.9091, 29.97), + ("NTSC DV Widescreen", 720, 480, 1.2121, 29.97), + ("NTSC D1", 720, 486, 0.9091, 29.97), + ("NTSC D1 Widescreen", 720, 486, 1.2121, 29.97), + ("NTSC D1 Square Pix", 720, 540, 1.0, 29.97), + ("NTSC D1 Widescreen Square Pix", 872, 486, 1.0, 29.97), + ("PAL D1/DV", 720, 576, 1.0940, 25.0), + ("PAL D1/DV Widescreen", 720, 576, 1.4587, 25.0), + ("PAL D1/DV Square Pix", 788, 576, 1.0, 25.0), + ("PAL D1/DV Widescreen Square Pix", 1050, 576, 1.0, 25.0), + ("HDV/HDTV 720 29.97", 1280, 720, 1.0, 29.97), + ("HDV/HDTV 720 25", 1280, 720, 1.0, 25.0), + ("DVCPRO HD 720 23.976", 960, 720, 1.3333, 23.976), + ("DVCPRO HD 720 25", 960, 720, 1.3333, 25.0), + ("DVCPRO HD 720 29.97", 960, 720, 1.3333, 29.97), + ("HDTV 1080 24", 1920, 1080, 1.0, 24.0), + ("HDTV 1080 25", 1920, 1080, 1.0, 25.0), + ("HDTV 1080 29.97", 1920, 1080, 1.0, 29.97), + ("HDV 1080 25", 1440, 1080, 1.3333, 25.0), + ("HDV 1080 29.97", 1440, 1080, 1.3333, 29.97), + ("DVCPRO HD 1080 25", 1440, 1080, 1.3333, 25.0), + ("DVCPRO HD 1080 29.97", 1280, 1080, 1.5, 29.97), + ("Cineon Half", 1828, 1332, 1.0, 24.0), + ("Cineon Full", 3656, 2664, 1.0, 24.0), + ("Film (2K)", 2048, 1556, 1.0, 24.0), + ("Film (4K)", 4096, 3112, 1.0, 24.0), + ("UHD 4K 23.976", 3840, 2160, 1.0, 23.976), + ("UHD 4K 25", 3840, 2160, 1.0, 25.0), + ("UHD 4K 29.97", 3840, 2160, 1.0, 29.97), + ("UHD 8K 23.976", 7680, 4320, 1.0, 23.976), + ("UHD 8K 25", 7680, 4320, 1.0, 25.0), + ("UHD 8K 29.97", 7680, 4320, 1.0, 29.97), +]; + +/// AE's Pixel Aspect Ratio choices. +pub const PIXEL_ASPECTS: &[(&str, f64)] = &[ + ("Square Pixels", 1.0), + ("D1/DV NTSC (0.91)", 0.9091), + ("D1/DV NTSC Widescreen (1.21)", 1.2121), + ("D1/DV PAL (1.09)", 1.0940), + ("D1/DV PAL Widescreen (1.46)", 1.4587), + ("Anamorphic 2:1 (2)", 2.0), + ("HDV 1080/DVCPRO HD 720 (1.33)", 1.3333), + ("DVCPRO HD 1080 (1.5)", 1.5), +]; + +const FRAME_RATES: &[f64] = &[8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0, 120.0]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + Basic, + Advanced, + Renderer, +} + +/// The dialog's working copy. +#[derive(Clone, Debug)] +pub struct CompDraft { + pub name: String, + pub width: u32, + pub height: u32, + pub pixel_aspect: f64, + pub fps: f64, + pub duration: f64, + pub start: f64, + pub bg: [f32; 3], + pub lock_aspect: bool, + pub anchor: u8, + pub shutter_angle: f64, + pub shutter_phase: f64, + pub samples: u32, + pub advanced_3d: bool, + pub tab: Tab, + /// Timecode text being edited (start, duration). + pub start_tc: Option, + pub dur_tc: Option, +} + +impl Default for CompDraft { + fn default() -> Self { + CompDraft { + name: "Comp 1".into(), + width: 1920, + height: 1080, + pixel_aspect: 1.0, + fps: 29.97, + duration: 10.0, + start: 0.0, + bg: [0.0, 0.0, 0.0], + lock_aspect: true, + anchor: 4, + shutter_angle: 180.0, + shutter_phase: -90.0, + samples: 16, + advanced_3d: false, + tab: Tab::Basic, + start_tc: None, + dur_tc: None, + } + } +} + +/// `16:9 (1.78)`: the nearest small whole-number ratio and its decimal. +pub fn aspect_label(w: f64, h: f64) -> String { + let r = w / h.max(1.0); + let best = (1..=32u32).map(|b| ((r * b as f64).round() as u32, b)).filter(|(a, _)| *a > 0).find(|(a, b)| (*a as f64 / *b as f64 - r).abs() < 0.006); + match best { + Some((a, b)) => format!("{a}:{b} ({r:.2})"), + None => format!("{r:.2}"), + } +} + +/// AE's name for a background color when it is one of the obvious ones. +fn color_name(c: [f32; 3]) -> &'static str { + match c.map(|v| (v * 255.0).round() as u8) { + [0, 0, 0] => "Black", + [255, 255, 255] => "White", + _ => "", + } +} + +/// AE prints the hour without a leading zero: `0;00;10;00`. +fn ae_timecode(smpte: &str) -> String { + let (neg, rest) = smpte.strip_prefix('-').map_or((false, smpte), |r| (true, r)); + let rest = rest.strip_prefix('0').filter(|r| r.as_bytes().first().is_some_and(u8::is_ascii_digit)).unwrap_or(rest); + format!("{}{rest}", if neg { "-" } else { "" }) +} + +fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, rate: FrameRate, min_frames: i64) { + let df = rate.supports_drop_frame(); + // Durations are whole frames in AE: 10 s at 29.97 fps is 0;00;10;00, not 9;29. + let shown = ae_timecode(&format_timecode_frames((*secs * rate.as_f64()).round() as i64, rate, df)); + let mut text = buf.clone().unwrap_or_else(|| shown.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0).font(Tokens::mono(12.0))); + if r.changed() { + *buf = Some(text.clone()); + } + if r.lost_focus() { + if let Ok(f) = parse_timecode(&text, rate, df, 0) { + *secs = rate.tick_of(f.max(min_frames)).seconds(); + } + *buf = None; + } + let base = format!("Base {}{}", rate.timecode_base(), if df { "drop" } else { "" }); + ui.label(egui::RichText::new(format!("is {shown} {base}")).color(Color32::GRAY)); +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. +pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option { + let mut out = None; + ui.horizontal(|ui| { + ui.label("Composition Name:"); + ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + for (tab, label) in [(Tab::Basic, "Basic"), (Tab::Advanced, "Advanced"), (Tab::Renderer, "3D Renderer")] { + if ui.selectable_label(d.tab == tab, label).clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + let rate = FrameRate::from_f64(d.fps); + match d.tab { + Tab::Basic => { + egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { + ui.label("Preset:"); + let current = PRESETS + .iter() + .find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3) + .map(|p| p.0) + .unwrap_or("Custom"); + egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { + for (name, w, h, par, f) in PRESETS { + if ui.selectable_label(*name == current, *name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + } + } + }); + ui.end_row(); + ui.label("Width:"); + ui.horizontal(|ui| { + let (ow, oh) = (d.width, d.height); + ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); + let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); + ui.checkbox(&mut d.lock_aspect, lbl); + if d.lock_aspect && ow != d.width && ow > 0 { + d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32; + } + }); + ui.end_row(); + ui.label("Height:"); + let (ow, oh) = (d.width, d.height); + ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); + if d.lock_aspect && oh != d.height && oh > 0 { + d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32; + } + ui.end_row(); + ui.label("Pixel Aspect Ratio:"); + ui.horizontal(|ui| { + let cur = PIXEL_ASPECTS + .iter() + .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) + .map(|p| p.0.to_string()) + .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); + egui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { + for (name, v) in PIXEL_ASPECTS { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + d.pixel_aspect = *v; + } + } + }); + ui.label( + egui::RichText::new(format!("Frame Aspect Ratio: {}", aspect_label(d.width as f64 * d.pixel_aspect, d.height as f64))) + .color(Color32::GRAY), + ); + }); + ui.end_row(); + ui.label("Frame Rate:"); + ui.horizontal(|ui| { + let txt = format!("{:.3}", d.fps).trim_end_matches('0').trim_end_matches('.').to_string(); + egui::ComboBox::from_id_salt("comp-fps").width(80.0).selected_text(txt).show_ui(ui, |ui| { + for f in FRAME_RATES { + if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() { + d.fps = *f; + } + } + }); + ui.label("frames per second"); + if rate.supports_drop_frame() { + ui.label(egui::RichText::new("Drop Frame").color(Color32::GRAY)); + } + }); + ui.end_row(); + ui.label(""); + let mb = d.width as f64 * d.height as f64 * 4.0 / (1024.0 * 1024.0); + ui.label(egui::RichText::new(format!("{} x {}, {mb:.1}MB per 8bpc frame", d.width, d.height)).color(Color32::GRAY)); + ui.end_row(); + ui.label("Start Timecode:"); + ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)); + ui.end_row(); + ui.label("Duration:"); + ui.horizontal(|ui| timecode_field(ui, &mut d.dur_tc, &mut d.duration, rate, 1)); + ui.end_row(); + ui.label("Background Color:"); + ui.horizontal(|ui| { + ui.color_edit_button_rgb(&mut d.bg); + ui.label(color_name(d.bg)); + }); + ui.end_row(); + }); + } + Tab::Advanced => { + egui::Grid::new("comp-adv").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { + ui.label("Anchor:"); + egui::Grid::new("comp-anchor").spacing([2.0, 2.0]).show(ui, |ui| { + for i in 0..9u8 { + let on = d.anchor == i; + let b = egui::Button::new(if on { "●" } else { "" }).min_size(vec2(22.0, 22.0)).fill(if on { t.accent } else { t.field_bg }); + if ui.add(b).clicked() { + d.anchor = i; + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); + ui.end_row(); + ui.label(egui::RichText::new("Motion Blur").strong()); + ui.end_row(); + ui.label("Shutter Angle:"); + ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°")); + ui.end_row(); + ui.label("Shutter Phase:"); + ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°")); + ui.end_row(); + ui.label("Samples Per Frame:"); + ui.add(egui::DragValue::new(&mut d.samples).range(2..=64)); + ui.end_row(); + }); + } + Tab::Renderer => { + ui.horizontal(|ui| { + ui.label("Renderer:"); + egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| { + ui.selectable_value(&mut d.advanced_3d, false, "Classic 3D"); + ui.selectable_value(&mut d.advanced_3d, true, "Advanced 3D"); + }); + }); + } + } + ui.add_space(14.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() { + out = Some(params(d)); + } + if ui.button("Cancel").clicked() { + out = Some(serde_json::Value::Null); + } + }); + }); + out +} + +/// `comp.new` / `comp.settings` parameters for the draft. +pub fn params(d: &CompDraft) -> serde_json::Value { + json!({ + "name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps, + "duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor, + "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, + "renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aspect_labels_match_ae() { + assert_eq!(aspect_label(1920.0, 1080.0), "16:9 (1.78)"); + assert_eq!(aspect_label(1080.0, 1080.0), "1:1 (1.00)"); + assert_eq!(aspect_label(1080.0, 1350.0), "4:5 (0.80)"); + assert_eq!(aspect_label(720.0 * 0.9091, 480.0), "15:11 (1.36)"); + } + + #[test] + fn ae_timecode_drops_hour_padding() { + assert_eq!(ae_timecode("00;00;10;00"), "0;00;10;00"); + assert_eq!(ae_timecode("12:00:00:00"), "12:00:00:00"); + assert_eq!(ae_timecode("-00:00:01:00"), "-0:00:01:00"); + } + + #[test] + fn presets_resolve_by_value() { + let p = PRESETS.iter().find(|p| p.0 == "HDTV 1080 29.97").unwrap(); + assert_eq!((p.1, p.2), (1920, 1080)); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 87a2a2cfd..a78a52a6c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -8,22 +8,7 @@ use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp, widgets}; -#[derive(Clone, Debug)] -pub struct CompDraft { - pub name: String, - pub width: u32, - pub height: u32, - pub fps: f64, - pub duration: f64, - pub bg: [f32; 3], - pub lock_aspect: bool, -} - -impl Default for CompDraft { - fn default() -> Self { - CompDraft { name: "Comp 1".into(), width: 1920, height: 1080, fps: 29.97, duration: 10.0, bg: [0.0, 0.0, 0.0], lock_aspect: true } - } -} +pub use super::comp_settings::CompDraft; #[derive(Clone, Debug, Default)] pub struct DialogState { @@ -50,10 +35,16 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { name: app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(), width: c.width, height: c.height, + pixel_aspect: c.pixel_aspect, fps: c.frame_rate.as_f64(), duration: c.duration.seconds(), + start: c.display_start.seconds(), bg: c.background, - lock_aspect: true, + shutter_angle: c.shutter_angle, + shutter_phase: c.shutter_phase, + samples: c.motion_blur_samples, + advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D, + ..Default::default() }; app.dialog_state.editing_existing = true; app.dialog = Some(Dialog::CompSettings); @@ -172,93 +163,25 @@ fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) { - let mut close = false; - let mut ok = false; let mut d = app.dialog_state.comp.clone(); - modal(ctx, if existing { "Composition Settings" } else { "Composition Settings — New" }, vec2(520.0, 420.0), t, |ui| { - egui::Grid::new("comp-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { - ui.label("Composition Name"); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); - ui.end_row(); - ui.label("Preset"); - egui::ComboBox::from_id_salt("comp-preset").selected_text(format!("{}x{} {:.2}", d.width, d.height, d.fps)).show_ui(ui, |ui| { - for (label, w, h, f) in [ - ("HD · 1920x1080 · 29.97 fps", 1920, 1080, 29.97), - ("HD · 1920x1080 · 25 fps", 1920, 1080, 25.0), - ("HD · 1920x1080 · 23.976 fps", 1920, 1080, 23.976), - ("HD · 1280x720 · 29.97 fps", 1280, 720, 29.97), - ("UHD 4K · 3840x2160 · 29.97 fps", 3840, 2160, 29.97), - ("Social 9:16 · 1080x1920 · 30 fps", 1080, 1920, 30.0), - ("Social 1:1 · 1080x1080 · 30 fps", 1080, 1080, 30.0), - ("Social 4:5 · 1080x1350 · 30 fps", 1080, 1350, 30.0), - ("Cinema 4K · 4096x2160 · 24 fps", 4096, 2160, 24.0), - ] { - if ui.selectable_label(false, label).clicked() { - d.width = w; - d.height = h; - d.fps = f; - } - } - }); - ui.end_row(); - ui.label("Width / Height"); - ui.horizontal(|ui| { - let ow = d.width; - ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); - ui.label("×"); - let oh = d.height; - ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); - ui.checkbox(&mut d.lock_aspect, "Lock aspect"); - if d.lock_aspect && ow != d.width && ow > 0 { - d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32; - } - }); - ui.end_row(); - ui.label("Frame Rate"); - egui::ComboBox::from_id_salt("comp-fps").selected_text(format!("{:.3}", d.fps).trim_end_matches('0').trim_end_matches('.').to_string()).show_ui( - ui, - |ui| { - for f in [23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 59.94, 60.0, 120.0] { - if ui.selectable_label((d.fps - f).abs() < 1e-3, format!("{f}")).clicked() { - d.fps = f; - } - } - }, - ); - ui.end_row(); - ui.label("Duration"); - ui.add(egui::DragValue::new(&mut d.duration).range(0.04..=86400.0).speed(0.1).suffix(" s")); - ui.end_row(); - ui.label("Background Color"); - ui.color_edit_button_rgb(&mut d.bg); - ui.end_row(); - }); - ui.add_space(18.0); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() { - ok = true; - } - if ui.button("Cancel").clicked() { - close = true; - } - }); - }); + let mut result = None; + modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { + result = super::comp_settings::show(ui, &mut d, t); }); - if ui_enter(ctx) { - ok = true; + if result.is_none() && ui_enter(ctx) { + result = Some(super::comp_settings::params(&d)); } - app.dialog_state.comp = d.clone(); - if ok { - let params = json!({"name": d.name, "width": d.width, "height": d.height, "frameRate": d.fps, "duration": d.duration, "background": [d.bg[0], d.bg[1], d.bg[2]]}); - let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; - if let Err(e) = r { - app.ui.status = e.to_string(); + app.dialog_state.comp = d; + match result { + Some(serde_json::Value::Null) => app.dialog = None, + Some(params) => { + let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; + if let Err(e) = r { + app.ui.status = e.to_string(); + } + app.dialog = None; } - close = true; - } - if close { - app.dialog = None; + None => {} } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2d5924dd0..f075f0aa7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panel bodies. +pub mod comp_settings; pub mod dialogs; pub mod effect_controls; pub mod effects_presets; From 89f10ba11ff5a025d9c0c2ef7409d76897d0b609 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:38 -0400 Subject: [PATCH 13/32] M3.1: AE Solid Settings dialog (lock aspect, % of comp, frame aspect, Make Comp Size) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 57 ++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a78a52a6c..82ff905d2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -195,23 +195,55 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut name = app.dialog_state.solid_name.clone(); let mut col = app.dialog_state.solid_color; let mut size = app.dialog_state.solid_size; - modal(ctx, "Solid Settings", vec2(420.0, 300.0), t, |ui| { - egui::Grid::new("solid-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { - ui.label("Name"); - ui.add(egui::TextEdit::singleline(&mut name).desired_width(220.0)); - ui.end_row(); - ui.label("Size"); + let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); + let lock_id = egui::Id::new("solid-lock-aspect"); + let mut lock = ctx.data(|d| d.get_temp::(lock_id)).unwrap_or(true); + modal(ctx, "Solid Settings", vec2(460.0, 360.0), t, |ui| { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("Size").strong()); + egui::Grid::new("solid-grid").num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { + let (ow, oh) = (size[0], size[1]); + ui.label("Width:"); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000)); - ui.label("×"); - ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000)); + ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")); + ui.checkbox(&mut lock, format!("Lock Aspect Ratio to {}", super::comp_settings::aspect_label(ow as f64, oh as f64))); }); ui.end_row(); - ui.label("Color"); - ui.color_edit_button_rgb(&mut col); + ui.label("Height:"); + ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")); ui.end_row(); + if lock && ow > 0 && oh > 0 { + if size[0] != ow { + size[1] = ((size[0] as f64) * oh as f64 / ow as f64).round().max(1.0) as u32; + } else if size[1] != oh { + size[0] = ((size[1] as f64) * ow as f64 / oh as f64).round().max(1.0) as u32; + } + } }); - ui.add_space(18.0); + let pct = |a: u32, b: u32| 100.0 * a as f64 / b.max(1) as f64; + ui.label( + egui::RichText::new(format!( + "Width: {:.1}% of comp\nHeight: {:.1}% of comp\nFrame Aspect Ratio: {}", + pct(size[0], comp_size.0), + pct(size[1], comp_size.1), + super::comp_settings::aspect_label(size[0] as f64, size[1] as f64) + )) + .color(Color32::GRAY), + ); + ui.add_space(4.0); + if ui.button("Make Comp Size").clicked() { + size = [comp_size.0, comp_size.1]; + } + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Color:"); + ui.color_edit_button_rgb(&mut col); + }); + ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() { ok = true; @@ -221,6 +253,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); }); + ctx.data_mut(|d| d.insert_temp(lock_id, lock)); app.dialog_state.solid_name = name.clone(); app.dialog_state.solid_color = col; app.dialog_state.solid_size = size; From 941e08744629edcd935a15eedcca549d87f3aad1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:42 -0400 Subject: [PATCH 14/32] M5.6: inline expression editor, expression pick-whip, parent pick-whip - Properties with an expression get an editor row in the timeline: Expression label, enable (=) switch, pick whip, syntax-error warning, and a multiline code editor in the time-graph area (commits on blur or Cmd/Ctrl+Enter via prop.setExpression). - Expression pick whip: drag onto another property row -> prop.pickWhip (AE reference expression). Parent pick whip in Parent & Link: drag onto a layer -> layer.setParent. - Alt-click on a stopwatch starts with the property's own reference (transform.opacity). - Timeline rows have variable heights; the empty-graph box-select area sits under the rows. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/prop.rs | 11 +- crates/engine/src/tests_timeline.rs | 14 ++ crates/ui-egui/src/panels/timeline.rs | 177 ++++++++++++++++++++++++-- 3 files changed, 181 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 8320cf047..4895bc5f5 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -148,6 +148,8 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { if error.is_some() { enabled = Some(false); } + // Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`). + let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into()); let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { match (&text, enabled) { (Some(t), _) if t.trim().is_empty() => pr.expr = None, @@ -156,11 +158,11 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { if let Some(x) = &mut pr.expr { x.enabled = e; } else if e { - pr.expr = Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }); + pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true }); } } (None, None) => { - pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }) }; + pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) }; } } Ok(json!(pr.expr.as_ref().map(|e| e.text.clone()))) @@ -172,11 +174,6 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { Ok(r) } -fn default_expr(pr: &Property) -> String { - let _ = pr; - "value".to_string() -} - fn reset(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?; let def = p.get("default").cloned(); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 44f8043e3..9772a5a11 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -240,6 +240,20 @@ fn time_reverse_keyframes() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0))); } +#[test] +fn alt_click_stopwatch_toggles_a_self_reference_expression() { + let (mut s, l) = setup(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": false})).unwrap(); + assert!(!prop(&s, l, "transform/opacity").expr.unwrap().enabled); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": true})).unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled); +} + #[test] fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 4cc8d3491..c83c9de8f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -21,8 +21,54 @@ use crate::{EffectcraftApp, widgets}; #[derive(Clone, Debug)] enum RowKind { Layer, - Group { uid: u64, name: String, open: bool, has_children: bool, fx: Option }, - Prop { uid: u64 }, + Group { + uid: u64, + name: String, + open: bool, + has_children: bool, + fx: Option, + }, + Prop { + uid: u64, + }, + /// Inline expression editor under a property with an expression. + Expr { + uid: u64, + lines: usize, + }, +} + +/// Row height (expression editors grow with their text). +fn row_height(row: &Row, rh: f32) -> f32 { + match row.kind { + RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0, + _ => rh, + } +} + +/// Insert an expression editor row after every property that has an expression. +fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + let extra = match r.kind { + RowKind::Prop { uid } if !closed.contains(&uid) => comp.layer(r.layer).and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { + layer: r.layer, + depth: r.depth, + kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, + }), + _ => None, + }; + out.push(r); + out.extend(extra); + } + out +} + +/// A pick-whip drag in progress: (parent = 0 / property = 1, layer, prop uid, start point). +type PickWhip = (u8, u64, u64, Pos2); + +fn pick_whip_id() -> egui::Id { + egui::Id::new("tl-pickwhip") } #[derive(Clone, Debug)] @@ -536,9 +582,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = cw.end; // ---- rows. - let rows = build_rows(app, &comp); + let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed); let rh = t.row_h; - let total_h = rows.len() as f32 * rh; + let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); @@ -567,18 +613,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let full_clip = ui.clip_rect(); let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y))); let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + // Registered before the rows so keys, bars and editors on top of it get the pointer first. + let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; + let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); + let mut hit_rows: Vec<(Rect, Row)> = vec![]; for (ri, row) in rows.iter().enumerate() { // Outline widgets never spill into the time graph (narrow timelines). ui.set_clip_rect(left_clip); - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), rh)); - y += rh; + let hh = row_height(row, rh); + let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh)); + y += hh; if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y { continue; } + hit_rows.push((r, row.clone())); let Some(layer) = comp.layer(row.layer) else { continue }; let is_sel = selected.contains(&layer.id); let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y)); - let cy = r.center().y; + let cy = r.min.y + rh / 2.0; match &row.kind { RowKind::Layer => { lp.rect_filled( @@ -760,7 +812,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Parent. - let pr_rect = Rect::from_min_size(pos2(cw.parent + 2.0, cy - 9.0), vec2(110.0, 18.0)); + let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)); + let pw = ui.interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()).on_hover_text("Parent pick whip: drag onto a layer"); + icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip"); + if pw.drag_started() { + let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center()); + ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); + } + let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0)); let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into()); let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0))); app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent"); @@ -895,6 +955,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::ToggleGroup(*uid)); } } + RowKind::Expr { uid, lines } => { + let Some(prop) = layer.props.find(*uid) else { continue }; + let Some(ex) = prop.expr.clone() else { continue }; + lp.rect_filled(left, 0.0, t.row_alt); + let indent = cw.name + 6.0 + 14.0 * row.depth as f32; + lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, "Expression:", Tokens::ui(11.5), t.text_dim); + lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text); + // "=" enable switch and the property pick whip (AE's expression controls). + let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0)); + let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text("Enable Expression"); + lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + let expr_col = Color32::from_rgb(0xe8, 0x7c, 0x5c); + lp.text( + en_r.center(), + Align2::CENTER_CENTER, + if ex.enabled { "=" } else { "≠" }, + Tokens::semibold(13.0), + if ex.enabled { expr_col } else { t.text_dim }, + ); + app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression"); + if en.clicked() { + actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled}))); + } + let pw_rect = Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)); + let pw = ui.interact(pw_rect, egui::Id::new(("expr-whip", uid)), Sense::drag()).on_hover_text("Expression pick whip: drag onto a property"); + icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.prop.{uid}.pickWhip"), pw_rect, "Expression pick whip"); + if pw.drag_started() { + let pw_state: PickWhip = (1, layer.id.0, *uid, pw_rect.center()); + ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); + } + // Syntax errors: warning in the outline (the expression is disabled). + if let Some(check) = app.session.expr_check + && let Err(e) = check(&ex.text) + { + let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0)); + lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30)); + let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e); + } + // The editor in the time-graph area. + ui.set_clip_rect(right_clip); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); + let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); + let buf_id = egui::Id::new(("expr-buf", uid)); + let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); + let resp = child.add( + egui::TextEdit::multiline(&mut buf) + .id(egui::Id::new(("expr-edit", uid))) + .font(Tokens::mono(11.5)) + .text_color(if ex.enabled { expr_col } else { t.text_dim }) + .desired_width(er.width()) + .desired_rows((*lines).clamp(1, 8)) + .frame(egui::Frame::NONE), + ); + app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); + let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); + if commit { + resp.surrender_focus(); + } + if resp.has_focus() && !commit { + ctx.data_mut(|d| d.insert_temp(buf_id, buf)); + } else { + if (resp.lost_focus() || commit) && buf.trim_end() != ex.text { + actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()}))); + } + ctx.data_mut(|d| d.remove::(buf_id)); + } + } RowKind::Prop { uid } => { let Some(prop) = layer.props.find(*uid) else { continue }; let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid)); @@ -983,7 +1112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); ui.set_clip_rect(right_clip); // Expression text row hint. - if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled) { + if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) { gp.text( pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, @@ -1078,13 +1207,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.set_clip_rect(full_clip); + // Pick whips: a line follows the pointer; releasing over a row links to it. + if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) { + let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start); + let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("tl-pickwhip-line"))); + fg.line_segment([start, ptr], Stroke::new(1.5, t.accent)); + fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent)); + let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); + if let Some((tr, _)) = &target { + fg.rect_stroke(Rect::from_min_max(tr.min, pos2(graph_x0 - 1.0, tr.max.y)), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + if ctx.input(|i| !i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(pick_whip_id())); + match (kind, target) { + (0, Some((_, row))) if row.layer.0 != src_layer => { + actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + } + (1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => { + actions.push(("prop.pickWhip".into(), json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}}))); + } + _ => {} + } + } + } // Graph editor. if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } // Empty-area click in the graph: deselect keys; drag: box-select keys. - let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; - let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); } @@ -1104,7 +1254,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y; for row in &rows { let cy = yy + rh / 2.0; - yy += rh; + yy += row_height(row, rh); if let RowKind::Prop { uid } = row.kind && cy >= br.min.y && cy <= br.max.y @@ -1180,8 +1330,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { let hover_y = ctx.input(|i| i.pointer.hover_pos()).map(|p| p.y).unwrap_or(0.0); - let row_i = ((hover_y - rows_rect.min.y + app.ui.timeline.scroll_y) / rh).floor() as usize; - let target = rows.get(row_i).map(|r| r.layer); + let target = hit_rows.iter().find(|(r, _)| hover_y >= r.min.y && hover_y < r.max.y).map(|(_, row)| row.layer); match payload.as_ref() { crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id}))), crate::panels::DragPayload::Effect(e) => { From 70c7f7c6167ef269964ca870128bc957ea4f50c8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:24 -0400 Subject: [PATCH 15/32] M9.3: Distort effects (CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, CC Smear, CC Split, CC Split 2, Smear, Reshape, Twirl (Legacy), Warp, Detail-preserving Upscale) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort3.rs | 1055 +++++++++++++++++++++++++++++++- 1 file changed, 1052 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 4dca9381e..e7ca49106 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1,7 +1,1056 @@ -//! Distort effects, batch 3. +//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, +//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the +//! fifteen warp styles) and Detail-preserving Upscale. +//! +//! All warps are inverse mapped: every output pixel finds the source point that lands on it. +//! Pixel-sized parameters are multiplied by the buffer scale (downsampled previews). -use crate::EffectSpec; +use std::f64::consts::{PI, TAU}; + +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, pick, point_in_poly, poly_length, poly_point_at, premul, remap, src_at, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +/// Mask choices for mask popups ("None", "Mask 1" … "Mask 8"). +const MASK_CHOICES: [&str; 9] = ["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8"]; + +/// The chosen mask as a polyline in buffer pixels (`None` for "None" or a missing mask). +fn mask_px(ctx: &EffectCtx, b: &Buf, id: &str) -> Option<(Vec<[f64; 2]>, bool)> { + let i = ctx.params.e(id) as usize; + if i == 0 { + return None; + } + let m = ctx.env.masks.get(i - 1)?; + if m.points.len() < 2 { + return None; + } + Some((m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed)) +} + +/// Resample a polyline to `n` points evenly spaced by arc length. +fn resample_poly(pts: &[[f64; 2]], closed: bool, n: usize) -> Vec<[f64; 2]> { + let len = poly_length(pts, closed); + let div = if closed { n as f64 } else { (n - 1).max(1) as f64 }; + (0..n).map(|i| poly_point_at(pts, closed, len * i as f64 / div).0).collect() +} + +fn centroid(pts: &[[f64; 2]]) -> [f64; 2] { + let n = pts.len().max(1) as f64; + let s = pts.iter().fold([0.0, 0.0], |a, q| [a[0] + q[0], a[1] + q[1]]); + [s[0] / n, s[1] / n] +} + +/// Elasticity popup (Stiff … Super Fluid) → inverse-distance weighting exponent. +const ELASTICITY: [&str; 8] = ["Stiff", "Less Stiff", "Below Normal", "Normal", "Above Normal", "Loose", "Liquid", "Super Fluid"]; +fn elastic_exp(i: u32) -> f64 { + [1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)] +} + +// ---------------------------------------------------------------- CC Bender + +fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount") / 100.0; + if amount == 0.0 { + return b; + } + let style = ctx.params.e("style"); + let top = b.to_px(ctx.params.v2("top")); + let base = b.to_px(ctx.params.v2("base")); + let (ax, ay) = (top.0 - base.0, top.1 - base.1); + let len = (ax * ax + ay * ay).sqrt().max(1e-6); + let (ux, uy) = (ax / len, ay / len); + // Perpendicular (to the right of the axis when it points up). + let (nx, ny) = (-uy, ux); + let reference = if ctx.params.b("adjustToDistance") { len } else { ctx.layer_size[1] * b.scale }; + let f = move |t: f64| -> f64 { + let t = t.clamp(0.0, 1.0); + match style { + 1 => 0.5 * (PI * t).sin() * t, // Marilyn: billowing middle + 2 => 0.5 * t, // Sharp: straight lean from the base + 3 => 0.25 * (1.0 - (PI * t).cos()) - 0.1 * (TAU * t).sin(), // Boxer: S-shaped + _ => 0.5 * t * t, // Bend: smooth curve + } + }; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - base.0, y - base.1); + let t = (rx * ux + ry * uy) / len; + let d = amount * reference * f(t); + Some((x - nx * d, y - ny * d)) + }); + b +} + +// ---------------------------------------------------------------- shading helpers + +/// Unit light vector from AE-style direction (0° = up, clockwise) and height (0–100 %). +fn distant_light(dir_deg: f64, height_pct: f64) -> [f64; 3] { + let el = (height_pct / 100.0).clamp(0.0, 1.0) * PI * 0.5; + let d = dir_deg.to_radians(); + [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()] +} + +fn norm3(v: [f64; 3]) -> [f64; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +// ---------------------------------------------------------------- CC Blobbylize + +fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let soft = ctx.params.f("softness").max(0.0) * b.scale; + let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32; + let blob = layer_or_self(ctx, &b, "blobLayer", true, false); + let prop = src_at(ctx.params.e("property")); + let raw = Plane::from_image(&blob, |px| { + let (c, a) = unpremul(px); + pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a } + }); + let h = gauss_plane(&raw, soft, soft); + let intensity = ctx.params.f("lightIntensity") / 100.0; + let lc = ctx.params.color("lightColor"); + let point = ctx.params.e("lightType") == 1; + let lpos = b.to_px(ctx.params.v2("lightPosition")); + let lheight = ctx.params.f("lightHeight"); + let ldir = distant_light(ctx.params.f("lightDirection"), lheight); + let ambient = ctx.params.f("ambient") / 100.0; + let diffuse = ctx.params.f("diffuse") / 100.0; + let specular = ctx.params.f("specular") / 100.0; + let rough = ctx.params.f("roughness").clamp(0.001, 1.0); + let metal = (ctx.params.f("metal") / 100.0) as f32; + let shininess = (1.0 / rough).min(500.0); + let depth = (soft * 0.5).max(1.0); + let src = b.img.clone(); + let (w, hh) = (b.img.width as usize, b.img.height as usize); + let span = (w.max(hh) as f64).max(1.0); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (xi, yi) = (x as i64, y as i64); + let hv = h.get(x, y); + let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; + let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; + // Refraction-like displacement down the slope, then the blobby matte. + let sx = x as f64 + 0.5 - gx * depth * 2.0; + let sy = y as f64 + 0.5 - gy * depth * 2.0; + let s = src.sample_bilinear(sx, sy); + let (c, a) = unpremul(s); + let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0); + let alpha = a.max(s[3]) * m; + if alpha <= 0.0 { + *px = [0.0; 4]; + continue; + } + let n = norm3([-gx * depth, -gy * depth, 1.0]); + let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir }; + let ndl = dot3(n, l).max(0.0); + let hv3 = norm3([l[0], l[1], l[2] + 1.0]); + let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular; + let mut out = [0.0f32; 3]; + for i in 0..3 { + let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i]; + let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i]; + out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col; + } + *px = premul(out, alpha); + } + }); + b +} + +// ---------------------------------------------------------------- CC Power Pin + +fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + // Source rectangle (layer space), grown by the Expand percentages. + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + // Grow the buffer to hold the pinned quad. + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners_layer { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(need.ceil().min(4096.0) as u32 + 1); + } + } + let q = corners_layer.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let src = b.img.clone(); + let (sc, off) = (b.scale, b.offset); + let bil = move |u: f64, v: f64| -> (f64, f64) { + let top = (q[0].x + (q[1].x - q[0].x) * u, q[0].y + (q[1].y - q[0].y) * u); + let bot = (q[3].x + (q[2].x - q[3].x) * u, q[3].y + (q[2].y - q[3].y) * u); + (top.0 + (bot.0 - top.0) * v, top.1 + (bot.1 - top.1) * v) + }; + b.img = crate::util::gen_image(src.width, src.height, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let m = inv.apply(vec2(px, py)); + let (mut u, mut v) = (m.x, m.y); + if persp < 1.0 { + // Bilinear (non-perspective) inverse by Newton iterations from the projective guess. + let (mut bu, mut bv) = (u, v); + for _ in 0..8 { + let (fx, fy) = bil(bu, bv); + let (rx, ry) = (fx - px, fy - py); + if rx.abs() + ry.abs() < 1e-6 { + break; + } + let e = 1e-4; + let (ax, ay) = bil(bu + e, bv); + let (cx, cy) = bil(bu, bv + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (cx - fx) / e, (cy - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-12 { + break; + } + bu -= (j11 * rx - j01 * ry) / det; + bv -= (-j10 * rx + j00 * ry) / det; + } + u = bu + (u - bu) * persp; + v = bv + (v - bv) * persp; + } + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let lx = x0 + u * (x1 - x0); + let ly = y0 + v * (y1 - y0); + src.sample_bilinear(lx * sc + off[0], ly * sc + off[1]) + }); + b +} + +// ---------------------------------------------------------------- CC Ripple Pulse + +fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; // the ring crosses half the layer in one time span + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + let wave = move |r: f64| -> f64 { + // A pulse train trailing the front, fading with age. + let age = (front - r) / speed.max(1e-9); + if age < 0.0 || age > span { + return 0.0; + } + let fade = 1.0 - age / span; + (TAU * (r - front) / lambda).sin() * fade + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt(); + let d = wave(r) * height; + let (ux, uy) = if r > 1e-9 { (dx / r, dy / r) } else { (0.0, 0.0) }; + let mut s = src.sample_bilinear(x as f64 + 0.5 - ux * d, y as f64 + 0.5 - uy * d); + if bump && d != 0.0 { + let slope = (wave(r + 0.5) - wave(r - 0.5)) * height; + let k = (1.0 - slope * 0.5).clamp(0.0, 2.0) as f32; + for v in s.iter_mut().take(3) { + *v *= k; + } + } + *px = s; + } + }); + b +} + +// ---------------------------------------------------------------- CC Slant + +fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("slant") / 100.0; + let height = ctx.params.f("height") / 100.0; + if k == 0.0 && (height - 1.0).abs() < 1e-12 { + return b; + } + let stretch = ctx.params.b("stretching"); + let xs = if stretch { 1.0 / (1.0 + k * k).sqrt() } else { 1.0 }; + let hinv = if height.abs() < 1e-6 { 1e6 } else { 1.0 / height }; + if !ctx.adjustment { + let lean = (k * ctx.layer_size[1] * b.scale * height.max(1.0)).abs(); + let grow = ((height - 1.0).max(0.0) * ctx.layer_size[1] * b.scale).max(lean); + b.pad(grow.ceil().min(4096.0) as u32); + } + let floor = b.to_px(ctx.params.v2("floor")); + b.img = remap(&b.img, false, |x, y| { + let sy = floor.1 + (y - floor.1) * hinv; + let sx = floor.0 + (x - floor.0 - (floor.1 - y) * k) / xs; + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- CC Smear + +fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let reach = ctx.params.f("reach") / 100.0; + let radius = (ctx.params.f("radius") * b.scale).max(0.5); + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let l2 = dx * dx + dy * dy; + if l2 < 1e-12 || reach == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + let t = (((x - from.0) * dx + (y - from.1) * dy) / l2).clamp(0.0, 1.0); + let (px, py) = (from.0 + dx * t, from.1 + dy * t); + let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt(); + let w = (1.0 - d / radius).max(0.0); + let w = w * w * (3.0 - 2.0 * w); + let k = reach * w * t; + Some((x - dx * k, y - dy * k)) + }); + b +} + +// ---------------------------------------------------------------- CC Split / CC Split 2 + +fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { + if s1 == 0.0 && s2 == 0.0 { + return b; + } + let a = b.to_px(ctx.params.v2("pointA")); + let c = b.to_px(ctx.params.v2("pointB")); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return b; + } + let (ux, uy) = (dx / len, dy / len); + let (nx, ny) = (-uy, ux); + let reach = len * 0.5; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - a.0, y - a.1); + let t = (rx * ux + ry * uy) / len; + let s = rx * nx + ry * ny; + if !(0.0..=1.0).contains(&t) { + return Some((x, y)); + } + let amt = if s >= 0.0 { s1 } else { s2 }; + let g = amt * reach * (PI * t).sin(); + let ad = s.abs(); + if ad < g { + return None; + } + // Push the sides apart, fading out over `reach` beyond the gap. + let push = g * (1.0 - (ad - g) / reach).max(0.0); + let ns = s.signum() * (ad - push); + Some((x + nx * (ns - s), y + ny * (ns - s))) + }); + b +} + +fn cc_split(ctx: &EffectCtx, b: Buf) -> Buf { + let s = ctx.params.f("split") / 100.0; + split_impl(ctx, b, s, s) +} + +fn cc_split2(ctx: &EffectCtx, b: Buf) -> Buf { + split_impl(ctx, b, ctx.params.f("split1") / 100.0, ctx.params.f("split2") / 100.0) +} + +// ---------------------------------------------------------------- Smear (mask based) + +fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((src_m, _)), Some((bound, _))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "boundaryMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + let off = ctx.params.v2("maskOffset"); + let (ox, oy) = (off[0] * b.scale, off[1] * b.scale); + let rot = ctx.params.f("maskRotation").to_radians(); + let scl = (ctx.params.f("maskScale") / 100.0).max(1e-3); + let kexp = elastic_exp(ctx.params.e("elasticity")); + let c = centroid(&src_m); + let (sn, cs) = rot.sin_cos(); + let fwd = |p: [f64; 2]| -> [f64; 2] { + let (dx, dy) = ((p[0] - c[0]) * scl, (p[1] - c[1]) * scl); + [c[0] + dx * cs - dy * sn + ox, c[1] + dx * sn + dy * cs + oy] + }; + let inv = |x: f64, y: f64| -> (f64, f64) { + let (dx, dy) = (x - ox - c[0], y - oy - c[1]); + (c[0] + (dx * cs + dy * sn) / scl, c[1] + (-dx * sn + dy * cs) / scl) + }; + let moved: Vec<[f64; 2]> = src_m.iter().map(|&q| fwd(q)).collect(); + if pct == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + if !point_in_poly(&bound, x, y) { + return Some((x, y)); + } + let w = if point_in_poly(&moved, x, y) { + 1.0 + } else { + let dm = dist_to_poly(&moved, true, x, y); + let db = dist_to_poly(&bound, true, x, y); + (db / (db + dm).max(1e-9)).powf(1.0 / kexp) + }; + let (ix, iy) = inv(x, y); + let k = (w * pct).clamp(0.0, 1.0); + Some((x + (ix - x) * k, y + (iy - y) * k)) + }); + b +} + +// ---------------------------------------------------------------- Reshape + +fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + if pct == 0.0 { + return b; + } + let bound = mask_px(ctx, &b, "boundaryMask").map(|m| m.0); + let kexp = elastic_exp(ctx.params.e("elasticity")); + const N: usize = 48; + let s = resample_poly(&sm, sc, N); + let d = resample_poly(&dm, dc, N); + // Align the starting correspondence point (closed shapes): best cyclic rotation. + let rot = if sc && dc { + (0..N) + .min_by(|&r1, &r2| { + let cost = |r: usize| (0..N).map(|i| (s[(i + r) % N][0] - d[i][0]).powi(2) + (s[(i + r) % N][1] - d[i][1]).powi(2)).sum::(); + cost(r1).total_cmp(&cost(r2)) + }) + .unwrap_or(0) + } else { + 0 + }; + let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect(); + let smooth = ctx.params.e("interpolation") == 2; + b.img = remap(&b.img, false, |x, y| { + if let Some(bd) = &bound + && !point_in_poly(bd, x, y) + { + return Some((x, y)); + } + let (mut wx, mut wy, mut ws) = (0.0, 0.0, 0.0); + let mut dmin = f64::INFINITY; + for i in 0..N { + let d2 = (x - d[i][0]).powi(2) + (y - d[i][1]).powi(2); + dmin = dmin.min(d2); + let w = 1.0 / (d2 + 1e-6).powf(kexp * 0.5); + wx += w * disp[i][0]; + wy += w * disp[i][1]; + ws += w; + } + let mut fade = 1.0; + if let Some(bd) = &bound { + let db = dist_to_poly(bd, true, x, y); + let dq = dmin.sqrt(); + fade = db / (db + dq).max(1e-9); + if smooth { + fade = fade * fade * (3.0 - 2.0 * fade); + } + } + let k = fade * pct / ws; + Some((x + wx * k, y + wy * k)) + }); + b +} + +// ---------------------------------------------------------------- Twirl (Legacy) + +fn twirl_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return b; + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + b.img = remap(&b.img, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let d = (dx * dx + dy * dy).sqrt(); + if d >= r { + return Some((x, y)); + } + // The legacy twirl falls off linearly with distance from the centre. + let a = -ang * (1.0 - d / r); + let (s, co) = a.sin_cos(); + Some((c.0 + dx * co - dy * s, c.1 + dx * s + dy * co)) + }); + b +} + +// ---------------------------------------------------------------- Warp + +const WARP_STYLES: [&str; 15] = + ["Arc", "Arc Lower", "Arc Upper", "Arch", "Bulge", "Shell Lower", "Shell Upper", "Flag", "Wave", "Fish", "Rise", "Fisheye", "Inflate", "Squeeze", "Twist"]; + +/// Forward warp in normalised coordinates (u across the warp axis' length, v across it, both +/// −1…1); `k` is Bend (−1…1). Returns the warped point. +fn warp_style(style: u32, k: f64, u: f64, v: f64) -> (f64, f64) { + let bell = 1.0 - u * u; + match style { + 0 => (u * (1.0 - 0.3 * k * v), v - k * bell), // Arc + 1 => (u, v - k * bell * (v + 1.0) * 0.5), // Arc Lower + 2 => (u, v - k * bell * (1.0 - v) * 0.5), // Arc Upper + 3 => (u, v - k * bell), // Arch + 4 => (u, v * (1.0 + k * bell)), // Bulge + 5 => (u, v + k * bell * bell * (v + 1.0) * 0.5), // Shell Lower + 6 => (u, v - k * bell * bell * (1.0 - v) * 0.5), // Shell Upper + 7 => (u, v - 0.5 * k * (PI * u).sin()), // Flag + 8 => (u, v - 0.5 * k * (PI * u + 0.5 * PI * v).sin()), // Wave + 9 => (u, v * (1.0 + 0.5 * k * (PI * u).sin())), // Fish + 10 => (u, v - 0.5 * k * (0.5 * PI * u).sin()), // Rise + 11 => { + // Fisheye: radial bulge inside the unit circle. + let r2 = u * u + v * v; + if r2 >= 1.0 { + (u, v) + } else { + let f = 1.0 + k * (1.0 - r2); + (u * f, v * f) + } + } + 12 => (u * (1.0 + 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Inflate + 13 => (u * (1.0 - 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Squeeze + _ => { + // Twist: rotation decreasing towards the edge of the unit circle. + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + (u, v) + } else { + let a = k * PI * (1.0 - r); + let (s, c) = a.sin_cos(); + (u * c - v * s, u * s + v * c) + } + } + } +} + +fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("bend") / 100.0; + let hd = ctx.params.f("horizontalDistortion") / 100.0; + let vd = ctx.params.f("verticalDistortion") / 100.0; + if k == 0.0 && hd == 0.0 && vd == 0.0 { + return b; + } + let style = ctx.params.e("warpStyle"); + let vertical = ctx.params.e("warpAxis") == 1; + let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5); + if !ctx.adjustment { + let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0); + b.pad(pad as u32 + 1); + } + let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]); + let (hx, hy) = (hx.max(1e-6), hy.max(1e-6)); + let fwd = move |x: f64, y: f64| -> (f64, f64) { + let (u0, v0) = ((x - c.0) / hx, (y - c.1) / hy); + let (u, v) = if vertical { (v0, u0) } else { (u0, v0) }; + let (wu, wv) = warp_style(style, k, u, v); + let (mut x1, mut y1) = if vertical { (wv, wu) } else { (wu, wv) }; + // Horizontal / vertical distortion: perspective-like size change across the layer. + y1 *= 1.0 + 0.5 * hd * x1; + x1 *= 1.0 + 0.5 * vd * y1; + (c.0 + x1 * hx, c.1 + y1 * hy) + }; + b.img = remap(&b.img, false, |x, y| { + let (mut sx, mut sy) = (x, y); + for _ in 0..12 { + let (fx, fy) = fwd(sx, sy); + let (rx, ry) = (fx - x, fy - y); + if rx.abs() + ry.abs() < 1e-7 { + break; + } + let e = 0.01; + let (ax, ay) = fwd(sx + e, sy); + let (bx, by) = fwd(sx, sy + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (bx - fx) / e, (by - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-9 { + return None; + } + sx -= (j11 * rx - j01 * ry) / det; + sy -= (-j10 * rx + j00 * ry) / det; + } + let (fx, fy) = fwd(sx, sy); + if (fx - x).abs() + (fy - y).abs() > 0.5 { + return None; + } + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- Detail-preserving Upscale + +/// Catmull-Rom weight. +fn cubic_w(t: f64) -> f64 { + let t = t.abs(); + if t < 1.0 { + 1.5 * t * t * t - 2.5 * t * t + 1.0 + } else if t < 2.0 { + -0.5 * t * t * t + 2.5 * t * t - 4.0 * t + 2.0 + } else { + 0.0 + } +} + +fn bicubic(img: &Image, x: f64, y: f64) -> Px { + let fx = x - 0.5; + let fy = y - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + let (tx, ty) = (fx - x0, fy - y0); + let wx = [cubic_w(1.0 + tx), cubic_w(tx), cubic_w(1.0 - tx), cubic_w(2.0 - tx)]; + let wy = [cubic_w(1.0 + ty), cubic_w(ty), cubic_w(1.0 - ty), cubic_w(2.0 - ty)]; + let mut o = [0.0f64; 4]; + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let p = img.get_clamped(x0 as i64 - 1 + i as i64, y0 as i64 - 1 + j as i64); + let w = wxi * wyj; + for c in 0..4 { + o[c] += p[c] as f64 * w; + } + } + } + let a = o[3].clamp(0.0, 1.0) as f32; + [(o[0] as f32).clamp(0.0, a.max(o[0] as f32)), (o[1] as f32).clamp(0.0, a.max(o[1] as f32)), (o[2] as f32).clamp(0.0, a.max(o[2] as f32)), a] +} + +fn upscale(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = (ctx.params.f("scale") / 100.0).clamp(1.0, 10.0); + if (k - 1.0).abs() < 1e-9 { + return b; + } + let detail = (ctx.params.f("detail") / 100.0) as f32; + let noise = ctx.params.f("reduceNoise") / 100.0; + let src = if noise > 0.0 { effectcraft_raster::gaussian_blur(&b.img, noise * 1.5, noise * 1.5, true) } else { b.img.clone() }; + let lc = [ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]; + let mut out = if ctx.adjustment { + // Scale in place about the layer centre. + let c = b.to_px(lc); + let mut o = Image::new(src.width, src.height); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let sx = c.0 + (x as f64 + 0.5 - c.0) / k; + let sy = c.1 + (y as f64 + 0.5 - c.1) / k; + *px = if sx < 0.0 || sy < 0.0 || sx > src.width as f64 || sy > src.height as f64 { [0.0; 4] } else { bicubic(&src, sx, sy) }; + } + }); + o + } else { + let w = ((src.width as f64 * k).round() as u32).clamp(1, 16384); + let h = ((src.height as f64 * k).round() as u32).clamp(1, 16384); + let (kx, ky) = (w as f64 / src.width as f64, h as f64 / src.height as f64); + let mut o = Image::new(w, h); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = bicubic(&src, (x as f64 + 0.5) / kx, (y as f64 + 0.5) / ky); + } + }); + let c_sc = [lc[0] * b.scale, lc[1] * b.scale]; + b.offset = [b.offset[0] * kx + c_sc[0] * (kx - 1.0), b.offset[1] * ky + c_sc[1] * (ky - 1.0)]; + o + }; + if detail > 0.0 { + let sigma = 0.6 * k; + let blur = effectcraft_raster::gaussian_blur(&out, sigma, sigma, true); + out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| { + let a = p[3]; + for c in 0..3 { + p[c] = (p[c] + (p[c] - q[c]) * detail * 1.5).clamp(0.0, a.max(p[c])); + } + }); + } + b.img = out; + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let pct = || slider(0.0, 100.0, 0.0, 100.0, 1); + vec![ + spec( + "ec.distort.ccbender", + "CC Bender", + vec![ + p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])), + p("adjustToDistance", "Adjust To Distance", Value::Bool(false), ParamUi::Checkbox), + p("top", "Top", pt(0.5, 0.0), ParamUi::Point), + p("base", "Base", pt(0.5, 1.0), ParamUi::Point), + ], + cc_bender, + ), + spec( + "ec.distort.ccblobbylize", + "CC Blobbylize", + vec![ + p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])), + p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)), + p("cutAway", "Cut Away", num(0.0), pct()), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), + p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("metal", "Metal", num(100.0), pct()), + ], + cc_blobbylize, + ), + spec( + "ec.distort.ccpowerpin", + "CC Power Pin", + vec![ + p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point), + p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point), + p("bottomLeft", "Bottom Left", pt(0.0, 1.0), ParamUi::Point), + p("bottomRight", "Bottom Right", pt(1.0, 1.0), ParamUi::Point), + p("perspective", "Perspective", num(100.0), pct()), + p("unstretch", "Unstretch", Value::Bool(false), ParamUi::Checkbox), + p("expandTop", "Top", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandLeft", "Left", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandRight", "Right", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandBottom", "Bottom", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + ], + cc_power_pin, + ), + spec( + "ec.distort.ccripplepulse", + "CC Ripple Pulse", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("amplitude", "Amplitude", num(100.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("renderBump", "Render Bump As Well", Value::Bool(false), ParamUi::Checkbox), + ], + cc_ripple_pulse, + ), + spec( + "ec.distort.ccslant", + "CC Slant", + vec![ + p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox), + p("height", "Height", num(100.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)), + p("floor", "Floor", pt(0.5, 1.0), ParamUi::Point), + ], + cc_slant, + ), + spec( + "ec.distort.ccsmear", + "CC Smear", + vec![ + p("from", "From", pt(0.35, 0.5), ParamUi::Point), + p("to", "To", pt(0.65, 0.5), ParamUi::Point), + p("reach", "Reach", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("radius", "Radius", num(50.0), slider(0.0, 4000.0, 0.0, 400.0, 1)), + ], + cc_smear, + ), + spec( + "ec.distort.ccsplit", + "CC Split", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split", "Split", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split, + ), + spec( + "ec.distort.ccsplit2", + "CC Split 2", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split1", "Split 1", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("split2", "Split 2", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split2, + ), + spec( + "ec.distort.smear", + "Smear", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("maskOffset", "Mask Offset", pt(0.0, 0.0), ParamUi::Point), + p("maskRotation", "Mask Rotation", num(0.0), ParamUi::Angle), + p("maskScale", "Mask Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(3), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + smear, + ), + spec( + "ec.distort.reshape", + "Reshape", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("destinationMask", "Destination Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + reshape, + ), + spec( + "ec.distort.twirllegacy", + "Twirl (Legacy)", + vec![ + p("angle", "Angle", num(0.0), ParamUi::Angle), + p("radius", "Twirl Radius", num(30.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("center", "Twirl Center", pt(0.5, 0.5), ParamUi::Point), + ], + twirl_legacy, + ), + spec( + "ec.distort.warp", + "Warp", + vec![ + p("warpStyle", "Warp Style", Value::Enum(0), popup(&WARP_STYLES)), + p("warpAxis", "Warp Axis", Value::Enum(0), popup(&["Horizontal", "Vertical"])), + p("bend", "Bend", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("horizontalDistortion", "Horizontal Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("verticalDistortion", "Vertical Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + ], + warp, + ), + spec( + "ec.distort.upscale", + "Detail-preserving Upscale", + vec![ + p("scale", "Scale", num(100.0), slider(100.0, 1000.0, 100.0, 400.0, 1)), + p("detail", "Detail", num(20.0), pct()), + p("reduceNoise", "Reduce Noise", num(0.0), pct()), + ], + upscale, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + fn img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) + } + + /// Run with Point defaults scaled to the layer (like `instantiate`). + fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [im.width as f64, im.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let env = EffectEnv { masks, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn maxd(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { + MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } + } + + #[test] + fn identity_at_neutral_settings() { + let im = img(40, 30); + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![]), + ("ec.distort.ccpowerpin", vec![]), + ("ec.distort.ccripplepulse", vec![]), + ("ec.distort.ccslant", vec![]), + ("ec.distort.ccsmear", vec![("reach", num(0.0))]), + ("ec.distort.ccsplit", vec![("split", num(0.0))]), + ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), + ("ec.distort.smear", vec![]), + ("ec.distort.reshape", vec![]), + ("ec.distort.twirllegacy", vec![]), + ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), + ("ec.distort.upscale", vec![]), + ]; + for (id, set) in cases { + let o = run(id, set, im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); + assert_eq!(o.offset, [0.0, 0.0], "{id}"); + } + // Power pin with perspective 0 at default corners is also identity. + let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-4); + } + + #[test] + fn all_deterministic() { + let im = img(36, 28); + let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), + ("ec.distort.ccblobbylize", vec![]), + ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), + ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), + ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), + ("ec.distort.ccsmear", vec![]), + ("ec.distort.ccsplit", vec![]), + ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), + ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), + ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), + ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), + ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), + ("ec.distort.upscale", vec![("scale", num(200.0))]), + ]; + for (id, set) in cases { + let a = run(id, set, im.clone(), &masks); + let b = run(id, set, im.clone(), &masks); + assert_eq!(a.img, b.img, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); + } + } + + #[test] + fn every_warp_style_runs_and_bends() { + let im = img(40, 30); + for s in 0..WARP_STYLES.len() as u32 { + for axis in 0..2 { + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as u32; + // The layer centre's neighbourhood is still made of source pixels. + assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); + assert!(o.img.width > im.width); + } + } + // Arc with positive bend raises the middle of the layer: the top edge in the middle + // moves up (opaque pixels above the original top). + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[1] as i64; + assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); + assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); + } + + #[test] + fn power_pin_moves_corners() { + let im = img(20, 20); + // Pin to the right half: output x in 10..20 shows the whole layer squeezed. + let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; + let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(o.img.get(3, 10)[3], 0.0); + let px = o.img.get(15, 10); + assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); + } + + #[test] + fn split_opens_a_gap() { + let im = img(40, 30); + let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); + assert_eq!(o.img.get(20, 15)[3], 0.0); + assert_eq!(o.img.get(2, 2), im.get(2, 2)); + } + + #[test] + fn slant_shears_from_floor() { + let im = img(20, 20); + let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as i64; + // Bottom row stays put; the top row shifts right by ~10 px. + assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); + assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); + } + + #[test] + fn reshape_moves_source_to_destination() { + // A bright square at the source mask; reshape to a shifted destination mask. + let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); + assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); + assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); + } + + #[test] + fn upscale_grows_about_centre() { + let im = img(20, 10); + let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); + assert_eq!((o.img.width, o.img.height), (40, 20)); + // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the + // centre (10,5) lands in the middle of the 40×20 buffer. + assert_eq!(o.offset, [10.0, 5.0]); + let c = o.img.sample_bilinear(20.0, 10.0); + assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); + } + + #[test] + fn twirl_legacy_rotates_inside_radius() { + let im = img(40, 40); + let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) > 0.1); + // Corners lie outside the radius. + assert_eq!(o.img.get(0, 0), im.get(0, 0)); + } + + #[test] + fn half_resolution_matches_scaled_geometry() { + // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. + let s = crate::find("ec.distort.ccbender").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("amount".into(), num(30.0)); + params.values.insert("top".into(), pt(20.0, 0.0)); + params.values.insert("base".into(), pt(20.0, 40.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; + let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); + let a = full.img.sample_bilinear(20.0, 4.0)[3]; + let b = half.img.sample_bilinear(10.0, 2.0)[3]; + assert!((a - b).abs() < 0.3); + } } From 685d6bcd04966e8c5e7ea70845f9a86c119e9d1e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:24 -0400 Subject: [PATCH 16/32] M9.3: Perspective + Utility effects (CC Environment, 3D Glasses, HDR Compander) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/perspective2.rs | 285 ++++++++++++++++++++++++++++- crates/effects/src/utility2.rs | 84 ++++++++- 2 files changed, 363 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs index 60c61d027..feed02864 100644 --- a/crates/effects/src/perspective2.rs +++ b/crates/effects/src/perspective2.rs @@ -1,7 +1,286 @@ -//! Perspective effects, batch 2. +//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer +//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a +//! left and right view layer). -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, fit_layer, gauss_plane, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, LayerPixels, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- CC Environment + +/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates. +fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) { + match mapping { + 1 => { + // Probe (mirror ball). + let m = 2.0 * (r[0] * r[0] + r[1] * r[1] + (r[2] + 1.0).powi(2)).sqrt().max(1e-9); + (0.5 + r[0] / m, 0.5 + r[1] / m) + } + 2 => { + // Vertical cross cube map: 3 faces wide, 4 tall. + let (ax, ay, az) = (r[0].abs(), r[1].abs(), r[2].abs()); + let (col, row, u, v) = if ax >= ay && ax >= az { + if r[0] > 0.0 { (2.0, 1.0, -r[2] / ax, r[1] / ax) } else { (0.0, 1.0, r[2] / ax, r[1] / ax) } + } else if ay >= az { + if r[1] < 0.0 { (1.0, 0.0, r[0] / ay, -r[2] / ay) } else { (1.0, 2.0, r[0] / ay, r[2] / ay) } + } else if r[2] > 0.0 { + (1.0, 1.0, r[0] / az, r[1] / az) + } else { + (1.0, 3.0, r[0] / az, -r[1] / az) + }; + ((col + (u + 1.0) * 0.5) / 3.0, (row + (v + 1.0) * 0.5) / 4.0) + } + _ => { + // Spherical (equirectangular). + let lon = r[0].atan2(r[2]); + let lat = r[1].clamp(-1.0, 1.0).asin(); + (0.5 + lon / (2.0 * PI), 0.5 + lat / PI) + } + } +} + +fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf { + let env = ctx.layer_param("environment", true).unwrap_or_else(|| LayerPixels { buf: b.clone(), size: ctx.layer_size }); + let env_img = if ctx.params.b("filterEnvironment") { + let s = 2.0 * env.buf.scale; + effectcraft_raster::gaussian_blur(&env.buf.img, s, s, true) + } else { + env.buf.img.clone() + }; + let mapping = ctx.params.e("mapping"); + let height = ctx.params.f("height") / 100.0 * 20.0; + // Relief: luminance inside, rounded off by the alpha edge. + let relief = Plane::from_image(&b.img, |px| { + let (c, a) = unpremul(px); + (luminance(c[0], c[1], c[2]) * 0.5 + 0.5) * a + }); + let soft = 1.5 * b.scale.max(0.25); + let hmap = gauss_plane(&relief, soft, soft); + let (es, eo, esz) = (env.buf.scale, env.buf.offset, env.size); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let a = src.get(x as i64, y as i64)[3]; + if a <= 0.0 { + *px = [0.0; 4]; + continue; + } + let (xi, yi) = (x as i64, y as i64); + let gx = (hmap.get_clamped(xi + 1, yi) - hmap.get_clamped(xi - 1, yi)) as f64 * 0.5 / b.scale.max(1e-6); + let gy = (hmap.get_clamped(xi, yi + 1) - hmap.get_clamped(xi, yi - 1)) as f64 * 0.5 / b.scale.max(1e-6); + let n = [-gx * height, -gy * height, 1.0]; + let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let n = [n[0] / l, n[1] / l, n[2] / l]; + // Reflect the view ray (towards −z) about the normal. + let d = -n[2]; + let r = [-2.0 * d * n[0], -2.0 * d * n[1], -1.0 - 2.0 * d * n[2]]; + let r = [r[0], r[1], -r[2]]; + let (u, v) = env_uv(mapping, r); + let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0]; + let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1]; + let e = env_img.sample_bilinear_clamped(ex, ey); + let (ec, _) = unpremul(e); + *px = premul(ec, a); + } + }); + b +} + +// ---------------------------------------------------------------- 3D Glasses + +const VIEWS: [&str; 9] = [ + "Stereo Pair (Side by Side)", + "Over Under", + "Interlace Upper L Lower R", + "Red Green LR", + "Red Blue LR", + "Balanced Colored Red Blue", + "Balanced Red Green LR", + "Balanced Red Blue LR", + "Difference", +]; + +fn shift(img: &Image, dx: f64, dy: f64) -> Image { + if dx == 0.0 && dy == 0.0 { + return img.clone(); + } + crate::util::gen_image(img.width, img.height, |x, y| img.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy)) +} + +fn lum(p: Px) -> f32 { + luminance(p[0], p[1], p[2]) +} + +fn glasses_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + let get = |id: &str| match ctx.layer_param(id, true) { + Some(o) => fit_layer(ctx, &b, &o, false), + None => b.img.clone(), + }; + let (mut l, mut r) = (get("leftView"), get("rightView")); + if ctx.params.b("leftRightSwap") { + std::mem::swap(&mut l, &mut r); + } + let pct = ctx.params.e("units") == 1; + let unit = if pct { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale; + let conv = ctx.params.f("sceneConvergence") * unit; + let va = ctx.params.f("verticalAlignment") * unit; + let l = shift(&l, conv * 0.5, 0.0); + let r = shift(&r, -conv * 0.5, va); + let view = ctx.params.e("view3d"); + let k = (ctx.params.f("balance") / 20.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width as f64, b.img.height as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + *px = match view { + 0 => { + if fx < w * 0.5 { + l.sample_bilinear(fx * 2.0, fy) + } else { + r.sample_bilinear((fx - w * 0.5) * 2.0, fy) + } + } + 1 => { + if fy < h * 0.5 { + l.sample_bilinear(fx, fy * 2.0) + } else { + r.sample_bilinear(fx, (fy - h * 0.5) * 2.0) + } + } + 2 => { + if y % 2 == 0 { + l.get(x as i64, y as i64) + } else { + r.get(x as i64, y as i64) + } + } + _ => { + let lp = l.get(x as i64, y as i64); + let rp = r.get(x as i64, y as i64); + let (lc, la) = unpremul(lp); + let (rc, ra) = unpremul(rp); + let a = la.max(ra); + let (ll, rl) = (lum(premul(lc, 1.0)), lum(premul(rc, 1.0))); + let c = match view { + 3 => [ll, rl, 0.0], + 4 => [ll, 0.0, rl], + 5 => [lc[0] + (ll - lc[0]) * k, rc[1], rc[2]], + 6 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, rl, 0.0], + 7 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, 0.0, rl], + _ => [(lc[0] - rc[0]).abs(), (lc[1] - rc[1]).abs(), (lc[2] - rc[2]).abs()], + }; + premul(c, a) + } + }; + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.perspective.ccenvironment", + "CC Environment", + vec![ + p("environment", "Environment", Value::Layer(None), ParamUi::Layer), + p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])), + p("filterEnvironment", "Filter Environment", Value::Bool(true), ParamUi::Checkbox), + p("height", "Height", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + cc_environment, + ), + spec( + "ec.perspective.3dglasses", + "3D Glasses", + vec![ + p("leftView", "Left View", Value::Layer(None), ParamUi::Layer), + p("rightView", "Right View", Value::Layer(None), ParamUi::Layer), + p("sceneConvergence", "Scene Convergence", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("verticalAlignment", "Vertical Alignment", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("units", "Units", Value::Enum(0), popup(&["Pixels", "% of Source"])), + p("leftRightSwap", "Left Right Swap", Value::Bool(false), ParamUi::Checkbox), + p("view3d", "3D View", Value::Enum(5), popup(&VIEWS)), + p("balance", "Balance", num(7.0), slider(0.0, 20.0, 0.0, 20.0, 1)), + ], + glasses_3d, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, run_fx}; + + fn img() -> Image { + crate::util::gen_image(32, 24, |x, y| { + let a = if (4..28).contains(&x) && (4..20).contains(&y) { 1.0 } else { 0.0 }; + [x as f32 / 32.0 * a, y as f32 / 24.0 * a, 0.4 * a, a] + }) + } + + struct Host(LayerPixels); + impl EffectHost for Host { + fn layer(&self, _: u64, _: bool) -> Option { + Some(self.0.clone()) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + #[test] + fn environment_reflects_env_layer() { + // A flat layer reflects the environment straight back: with a uniform green environment, + // opaque pixels become green, transparent stay transparent. + let env = LayerPixels { buf: Buf { img: Image::filled(16, 8, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [16.0, 8.0] }; + let host = Host(env); + let e = EffectEnv { host: Some(&host), ..Default::default() }; + let o = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e); + let px = o.img.get(16, 12); + assert!((px[1] - 1.0).abs() < 1e-4 && px[0].abs() < 1e-4, "{px:?}"); + assert_eq!(o.img.get(0, 0)[3], 0.0); + let again = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e); + assert_eq!(o.img, again.img); + for m in 0..3 { + let o = run_fx("ec.perspective.ccenvironment", &[("mapping", Value::Enum(m))], img(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } + } + + #[test] + fn glasses_anaglyph_and_pairs() { + let im = img(); + // Same view on both eyes: Red Blue LR puts the luminance in red and blue equally. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(4))], im.clone(), 0.0, EffectEnv::default()); + let px = o.img.get(16, 12); + assert!((px[0] - px[2]).abs() < 1e-5 && px[1] == 0.0 && px[0] > 0.0); + // Difference of identical views is black. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8))], im.clone(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| p[0].abs() < 1e-6 && p[1].abs() < 1e-6)); + // Convergence separates the eyes: difference becomes non-zero. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8)), ("sceneConvergence", num(4.0))], im.clone(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().any(|p| p[0] > 0.05)); + // Every view is deterministic. + for v in 0..VIEWS.len() as u32 { + let a = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img, b.img); + } + // Interlace: even rows = left view (self). + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(2))], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(o.img.get(10, 10), im.get(10, 10)); + } } diff --git a/crates/effects/src/utility2.rs b/crates/effects/src/utility2.rs index fcd43649a..ec7681970 100644 --- a/crates/effects/src/utility2.rs +++ b/crates/effects/src/utility2.rs @@ -1,7 +1,85 @@ -//! Utility effects, batch 2. +//! Utility effects, batch 2: HDR Compander. +//! +//! HDR Compander squeezes high-dynamic-range values into 0–1 (Compress Range) so 8/16 bpc +//! effects can process them, and restores them afterwards (Expand Range) with the same Gain and +//! Gamma; the two modes are exact inverses. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use rayon::prelude::*; + +use crate::util::{premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +#[inline] +fn spow(v: f32, e: f32) -> f32 { + v.signum() * v.abs().powf(e) +} + +fn hdr_compander(ctx: &EffectCtx, mut b: Buf) -> Buf { + let expand = ctx.params.e("mode") == 1; + let gain = ctx.params.f("gain").max(1e-6) as f32; + let gamma = ctx.params.f("gamma").max(1e-3) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let c = c.map(|v| if expand { spow(v, gamma) * gain } else { spow(v / gain, 1.0 / gamma) }); + *px = premul(c, a); + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![EffectSpec { + id: "ec.utility.hdrcompander", + name: "HDR Compander", + category: "Utility", + params: vec![ + p("mode", "Mode", Value::Enum(0), popup(&["Compress Range", "Expand Range"])), + p("gain", "Gain", num(1.0), slider(1.0, 100.0, 1.0, 100.0, 2)), + p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)), + ], + render: hdr_compander, + gpu: false, + float: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn hdr() -> Image { + crate::util::gen_image(8, 4, |x, y| { + let v = x as f32 * 0.6 + y as f32 * 0.1; + [v * 0.8, v * 0.4, v * 0.2, 0.8] + }) + } + + #[test] + fn defaults_are_identity_and_deterministic() { + let im = hdr(); + let a = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img, b.img); + assert!(a.img.data.iter().zip(&im.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() < 1e-5))); + } + + #[test] + fn compress_then_expand_round_trips() { + let im = hdr(); + let set = [("gain", num(5.0)), ("gamma", num(2.2))]; + let c = run_fx("ec.utility.hdrcompander", &set, im.clone(), 0.0, EffectEnv::default()); + // Compressed values fit in 0–1. + assert!(c.img.data.iter().all(|p| p[0] <= p[3] + 1e-5)); + let e = run_fx("ec.utility.hdrcompander", &[set[0].clone(), set[1].clone(), ("mode", Value::Enum(1))], c.img, 0.0, EffectEnv::default()); + for (p, q) in e.img.data.iter().zip(&im.data) { + for i in 0..4 { + assert!((p[i] - q[i]).abs() < 1e-3, "{p:?} vs {q:?}"); + } + } + } } From 5a308a6964319d9d05c55b74484464564c431d4f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:01:39 -0400 Subject: [PATCH 17/32] M0.9: Info panel shows selected layer Duration / In / Out (AE) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/info.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index eb370ecd0..cb51faf41 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -62,11 +62,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc = crate::panels::timecode(&app.session, &c, app.session.time()); p.text(pos2(x0, y), Align2::LEFT_CENTER, if sel.is_empty() { "No layer selected".to_string() } else { sel.join(", ") }, Tokens::semibold(12.0), t.text); p.text(pos2(x0, y + 18.0), Align2::LEFT_CENTER, format!("Time: {tc}"), Tokens::ui(12.0), t.text_dim); + // One layer selected: Duration, In and Out like AE's Info panel. + let mut ly = y + 36.0; + if let [id] = app.session.state.selected_layers.as_slice() + && let Some(l) = c.layer(*id) + { + let tc = |t| crate::panels::timecode(&app.session, &c, t); + let lines = [ + // A length, so no start-timecode offset. + format!("Duration: {}", { + let fr = c.frame_rate; + let n = fr.frame_at(l.out_point) - fr.frame_at(l.in_point); + let s = effectcraft_engine::time::format_timecode_frames(n, fr, fr.supports_drop_frame()); + if fr.supports_drop_frame() { s } else { s.replace(';', ":") } + }), + format!("In: {}, Out: {}", tc(l.in_point), tc(effectcraft_engine::time::Tick(l.out_point.0 - c.frame_duration().0))), + ]; + for line in lines { + p.text(pos2(x0, ly), Align2::LEFT_CENTER, line, Tokens::ui(12.0), t.text_dim); + ly += 18.0; + } + } let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); - p.text(pos2(x0, y + 36.0), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint); + p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps", app.fps), Tokens::ui(11.5), t.text_faint); if !app.session.state.selected_keys.is_empty() { p.text( - pos2(x0, y + 54.0), + pos2(x0, ly + 18.0), Align2::LEFT_CENTER, format!("{} keyframes selected", app.session.state.selected_keys.len()), Tokens::ui(11.5), From 7f9c3a85c005723fe262c588b19ba98d8a520d90 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:06 -0400 Subject: [PATCH 18/32] M10: render queue + export - crates/export (effectcraft-export): render a comp's span to H.264 MP4 (+AAC), ProRes 422/4444 MOV (+PCM, alpha), PNG/JPEG/TIFF/EXR sequences and animated GIF via FilmCraft's pure-Rust encoders and MP4/MOV muxer; parallel batched frame rendering, progress callback with cancellation. - project: Render Queue model (Render Settings, Output Module, output path templates, status) saved with the project. - render: FootageSource::audio hook + comp audio mixdown (levels, in/out, solo, precomps); media implements it. - engine: Exporter hook, background/blocking render job, renderQueue.* commands (add Cmd+M, remove, setRender, setRenderSettings, setOutputModule, setOutput, move, duplicate, render, stop, list, formats). - host wires the exporter; CLI `render` subcommand. - ui-egui: After Effects-style Render Queue panel with automation ids. - tests: encode/decode round trips for every format (FilmCraft decoders, ffmpeg/ffprobe oracle when installed), audio, cancel, engine commands. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 33 ++ Cargo.toml | 19 +- apps/effectcraft-cli/Cargo.toml | 1 + apps/effectcraft-cli/src/main.rs | 124 ++++- crates/engine/src/commands/mod.rs | 4 +- crates/engine/src/commands/render_queue.rs | 428 ++++++++++++++ crates/engine/src/lib.rs | 17 + crates/engine/src/render_queue.rs | 344 ++++++++++++ crates/engine/src/rq_tests.rs | 164 ++++++ crates/export/Cargo.toml | 29 + crates/export/src/encode.rs | 362 ++++++++++++ crates/export/src/lib.rs | 350 ++++++++++++ crates/export/tests/roundtrip.rs | 365 ++++++++++++ crates/host/Cargo.toml | 1 + crates/host/src/lib.rs | 85 ++- crates/media/src/pool.rs | 3 + crates/project/src/lib.rs | 6 +- crates/project/src/render_queue.rs | 525 ++++++++++++++++++ crates/render/src/audio.rs | 203 +++++++ crates/render/src/lib.rs | 6 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 19 + crates/ui-egui/src/panels/misc.rs | 24 +- crates/ui-egui/src/panels/mod.rs | 3 +- crates/ui-egui/src/panels/render_queue.rs | 504 +++++++++++++++++ crates/ui-egui/tests/render_queue_snapshot.rs | 43 ++ 26 files changed, 3629 insertions(+), 37 deletions(-) create mode 100644 crates/engine/src/commands/render_queue.rs create mode 100644 crates/engine/src/render_queue.rs create mode 100644 crates/engine/src/rq_tests.rs create mode 100644 crates/export/Cargo.toml create mode 100644 crates/export/src/encode.rs create mode 100644 crates/export/src/lib.rs create mode 100644 crates/export/tests/roundtrip.rs create mode 100644 crates/project/src/render_queue.rs create mode 100644 crates/render/src/audio.rs create mode 100644 crates/ui-egui/src/panels/render_queue.rs create mode 100644 crates/ui-egui/tests/render_queue_snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index b29b91818..210ba1963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,6 +1058,7 @@ name = "effectcraft-cli" version = "0.1.0" dependencies = [ "effectcraft-engine", + "effectcraft-host", "effectcraft-render", "effectcraft-time", "image", @@ -1104,6 +1105,27 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "effectcraft-export" +version = "0.1.0" +dependencies = [ + "effectcraft-color", + "effectcraft-media", + "effectcraft-project", + "effectcraft-raster", + "effectcraft-render", + "effectcraft-time", + "filmcraft-aac", + "filmcraft-h264enc", + "filmcraft-isobmff", + "filmcraft-prores", + "gif", + "image", + "log", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "effectcraft-expr" version = "0.1.0" @@ -1132,6 +1154,7 @@ name = "effectcraft-host" version = "0.1.0" dependencies = [ "effectcraft-engine", + "effectcraft-export", "effectcraft-expr", "effectcraft-media", "effectcraft-project", @@ -1696,6 +1719,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-h264enc" +version = "0.1.0" +source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-hevc" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4165cfef2..b042eff08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ effectcraft-text = { path = "crates/text" } effectcraft-effects = { path = "crates/effects" } effectcraft-render = { path = "crates/render" } effectcraft-media = { path = "crates/media" } +effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-engine = { path = "crates/engine" } effectcraft-host = { path = "crates/host" } @@ -35,6 +36,7 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" rayon = "1" +gif = "0.14" proptest = "1" # scripting (expressions) @@ -48,12 +50,17 @@ harfrust = { version = "0.12", default-features = false, features = ["std"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). -# Only `crates/media` (and later `crates/export`) may depend on these. +# Only `crates/media` and `crates/export` may depend on these. filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +# encoders + muxer (crates/export) +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" } # ui egui = "0.36" @@ -68,6 +75,10 @@ rfd = "0.17" # filmcraft-frame = { path = "../filmcraft/crates/frame" } # filmcraft-color = { path = "../filmcraft/crates/color" } # filmcraft-time = { path = "../filmcraft/crates/time" } +# filmcraft-h264enc = { path = "../filmcraft/crates/h264enc" } +# filmcraft-prores = { path = "../filmcraft/crates/prores" } +# filmcraft-aac = { path = "../filmcraft/crates/aac" } +# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" } [workspace.lints.rust] unsafe_code = "deny" @@ -101,10 +112,16 @@ opt-level = 3 [profile.dev.package.effectcraft-media] opt-level = 3 +[profile.dev.package.effectcraft-export] +opt-level = 3 + # FilmCraft codec hot loops (same settings as FilmCraft's own workspace). [profile.dev.package.filmcraft-h264] opt-level = 3 +[profile.dev.package.filmcraft-h264enc] +opt-level = 3 + [profile.dev.package.filmcraft-hevc] opt-level = 3 diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d06..ab97e6c53 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] effectcraft-engine = { workspace = true } +effectcraft-host = { workspace = true } effectcraft-render = { workspace = true } effectcraft-time = { workspace = true } serde_json = { workspace = true } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58f..d7f806b11 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -2,27 +2,141 @@ //! //! ```text //! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png +//! effectcraft-cli render [project.ecproj | --demo] [--comp NAME] --out FILE [--format F] [--start S] [--end S] +//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba] +//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] +//! effectcraft-cli render project.ecproj --queue (renders the project's Render Queue) //! effectcraft-cli run [project.ecproj | --demo] '' '' … (prints results) //! effectcraft-cli commands (lists commands) //! ``` +//! +//! `render` formats: h264 (.mp4), prores (.mov), png / jpeg / tiff / exr sequences (`name_[#####].png`), gif. +//! The format defaults to the `--out` extension; the time span to the whole comp. use effectcraft_engine::Session; +use effectcraft_engine::project::render_queue::RenderStatus; use effectcraft_render::RenderOpts; use effectcraft_time::Tick; use serde_json::{Value, json}; fn usage() -> ! { - eprintln!("usage: effectcraft-cli [args] (see --help in source docs)"); + eprintln!( + "usage: effectcraft-cli [args] + + frame [project.ecproj|--demo] [--comp NAME] [--time S|--frame N] [--scale K] --out out.png + render [project.ecproj|--demo] [--comp NAME] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif] + [--start S] [--end S] [--work-area] [--fps N] [--resolution full|half|third|quarter|K] + [--quality best|draft] [--channels rgb|rgba] [--jpeg-quality N] [--bitrate KBPS] + [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] + render project.ecproj --queue (render the project's Render Queue) + run [project.ecproj|--demo] '' '' … + commands" + ); std::process::exit(2) } +fn fail(msg: impl std::fmt::Display) -> ! { + eprintln!("effectcraft: {msg}"); + std::process::exit(1) +} + +/// Queue `--comp` (or the active comp) with the given settings unless `--queue`, then render the +/// queue with a progress line on stderr. Exit status 1 if any item fails. +fn render(s: &mut Session, rest: &[String]) { + if !rest.iter().any(|a| a == "--queue") { + let out = opt(rest, "--out").unwrap_or_else(|| fail("render: --out FILE is required (or --queue)")); + let mut p = json!({"output": out}); + if let Some(c) = opt(rest, "--comp") { + p["comp"] = json!(c); + } + let ext = std::path::Path::new(out).extension().map(|e| e.to_string_lossy().to_string()); + if let Some(f) = opt(rest, "--format").map(str::to_string).or(ext) { + p["format"] = json!(f); + } + let num = |k: &str| opt(rest, k).map(|v| v.parse::().unwrap_or_else(|_| fail(format!("{k}: not a number: {v}")))); + match (num("--start"), num("--end")) { + (None, None) => p["timeSpan"] = json!(if rest.iter().any(|a| a == "--work-area") { "workArea" } else { "comp" }), + (a, b) => { + p["start"] = json!(a.unwrap_or(0.0)); + if let Some(b) = b { + p["end"] = json!(b); + } + } + } + if let Some(v) = num("--fps") { + p["frameRate"] = json!(v); + } + if let Some(r) = opt(rest, "--resolution").or(opt(rest, "--scale")) { + p["resolution"] = r.parse::().map(|v| json!(v)).unwrap_or(json!(r)); + } + for (flag, key) in [("--quality", "quality"), ("--channels", "channels"), ("--prores", "proresProfile"), ("--audio", "audio")] { + if let Some(v) = opt(rest, flag) { + p[key] = json!(v); + } + } + if let Some(v) = num("--jpeg-quality") { + p["quality"] = json!(v); + } + if let Some(v) = num("--bitrate") { + p["bitrate"] = json!(v); + } + // Render exactly this item: unqueue whatever the project's queue already holds. + for k in 0..s.project.render_queue.len() { + let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false})); + } + let r = s.execute("renderQueue.add", p).unwrap_or_else(|e| fail(e)); + eprintln!( + "{} → {} ({}×{}, {} frames, {})", + r["compName"].as_str().unwrap_or("?"), + r["outputPath"].as_str().unwrap_or("?"), + r["width"], + r["height"], + r["frames"], + r["outputModuleSummary"].as_str().unwrap_or("") + ); + } + s.execute("renderQueue.render", json!({"wait": false})).unwrap_or_else(|e| fail(e)); + let tty = std::io::IsTerminal::is_terminal(&std::io::stderr()); + while s.is_rendering() { + std::thread::sleep(std::time::Duration::from_millis(100)); + if tty && let Some(p) = s.render_progress() { + let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default(); + eprint!("\r [{}/{}] frame {}/{} {:.1}s{left}\x1b[K", (p.items_done + 1).min(p.items_total), p.items_total, p.done, p.total, p.item_elapsed); + } + s.poll_render(); + } + s.poll_render(); + if tty { + eprintln!(); + } + let mut failed = false; + for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) { + let name = s.project.item(it.comp).map(|i| i.name.as_str()).unwrap_or("?"); + match &it.status { + RenderStatus::Done => eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0)), + RenderStatus::Failed(e) => { + failed = true; + eprintln!("failed: {name}: {e}"); + } + other => { + failed = true; + eprintln!("{name}: {}", other.label()); + } + } + } + if failed { + std::process::exit(1); + } +} + fn open(s: &mut Session, args: &[String]) { if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) { if let Err(e) = s.execute("file.open", json!({"path": p})) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // `--demo` (or no project): the built-in demo project. let _ = s.execute("file.openDemoProject", json!({})); } } @@ -35,8 +149,12 @@ fn main() { let args: Vec = std::env::args().skip(1).collect(); let Some(cmd) = args.first().cloned() else { usage() }; let rest = &args[1..]; - let mut s = Session::default(); + let mut s = effectcraft_host::session(); match cmd.as_str() { + "render" => { + open(&mut s, rest); + render(&mut s, rest); + } "frame" => { open(&mut s, rest); if let Some(c) = opt(rest, "--comp") { diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a86..452d8b71d 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -9,6 +9,7 @@ mod help; mod layer; mod prop; mod query; +mod render_queue; mod time; use std::sync::OnceLock; @@ -71,6 +72,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(prop::specs()); v.extend(effect::specs()); v.extend(time::specs()); + v.extend(render_queue::specs()); v.extend(help::specs()); v.extend(query::specs()); v @@ -182,6 +184,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { s.time() } -pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> { +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) } diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs new file mode 100644 index 000000000..10c44eb72 --- /dev/null +++ b/crates/engine/src/commands/render_queue.rs @@ -0,0 +1,428 @@ +//! Render Queue: Composition ▸ Add to Render Queue, the queue's Render Settings / Output Module / +//! Output To, Render and Stop. +//! +//! Items are addressed by `item` (the stable id from `renderQueue.add` / `renderQueue.list`) or +//! `index` (1-based, the # column). + +use effectcraft_project::render_queue::{ + AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQuality, RenderQueueItem, RenderSettings, RenderStatus, TimeSpan, +}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +fn can_add(s: &Session) -> std::result::Result<(), String> { + not_rendering(s)?; + if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) } +} +fn not_rendering(s: &Session) -> std::result::Result<(), String> { + if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) } +} +fn has_items(s: &Session) -> std::result::Result<(), String> { + not_rendering(s)?; + if s.project.render_queue.is_empty() { Err("the render queue is empty".into()) } else { Ok(()) } +} +fn has_queued(s: &Session) -> std::result::Result<(), String> { + not_rendering(s)?; + if s.exporter.is_none() { + return Err("export is not available in this build".into()); + } + if s.project.render_queue.iter().any(RenderQueueItem::is_queued) { Ok(()) } else { Err("nothing is queued".into()) } +} +fn rendering(s: &Session) -> std::result::Result<(), String> { + if s.is_rendering() { Ok(()) } else { Err("not rendering".into()) } +} + +/// Index of the item named by `item` (id) or `index` (1-based). +fn item_index(s: &Session, p: &Value, cmd: &str) -> Result { + let q = &s.project.render_queue; + if let Some(id) = p.get("item").and_then(Value::as_u64) { + return q.iter().position(|i| i.id == id).ok_or_else(|| bad(cmd, format!("no render queue item {id}"))); + } + if let Some(n) = p.get("index").and_then(Value::as_u64) { + return (n as usize).checked_sub(1).filter(|i| *i < q.len()).ok_or_else(|| bad(cmd, format!("no render queue item #{n}"))); + } + if q.len() == 1 { + return Ok(0); + } + Err(bad(cmd, "pass `item` (id) or `index` (1-based)")) +} + +fn time_p(p: &Value, k: &str) -> Option { + f_p(p, k).map(Tick::from_seconds_f64) +} + +/// Apply Render Settings parameters. +fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result { + let mut any = false; + if let Some(q) = str_p(p, "quality") { + rs.quality = match q.to_ascii_lowercase().as_str() { + "best" => RenderQuality::Best, + "draft" => RenderQuality::Draft, + _ => return Err(bad(cmd, "quality: best|draft")), + }; + any = true; + } + match p.get("resolution") { + Some(Value::String(r)) => { + rs.resolution = match r.to_ascii_lowercase().as_str() { + "full" => 1.0, + "half" => 0.5, + "third" => 1.0 / 3.0, + "quarter" => 0.25, + _ => return Err(bad(cmd, "resolution: full|half|third|quarter|")), + }; + any = true; + } + Some(Value::Number(n)) => { + rs.resolution = n.as_f64().unwrap_or(1.0).clamp(0.01, 4.0); + any = true; + } + _ => {} + } + let (start, end) = (time_p(p, "start"), time_p(p, "end")); + if let Some(ts) = str_p(p, "timeSpan") { + rs.time_span = match ts.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "workarea" | "workareaonly" => TimeSpan::WorkArea, + "comp" | "lengthofcomp" | "full" => TimeSpan::LengthOfComp, + "custom" => TimeSpan::Custom { start: start.unwrap_or(Tick::ZERO), end: end.unwrap_or(Tick::from_seconds_f64(1.0)) }, + _ => return Err(bad(cmd, "timeSpan: workArea|comp|custom")), + }; + any = true; + } else if start.is_some() || end.is_some() { + let (a, b) = match rs.time_span { + TimeSpan::Custom { start, end } => (start, end), + _ => (Tick::ZERO, Tick(i64::MAX / 4)), + }; + rs.time_span = TimeSpan::Custom { start: start.unwrap_or(a), end: end.unwrap_or(b) }; + any = true; + } + if let TimeSpan::Custom { start, end } = rs.time_span + && end <= start + { + return Err(bad(cmd, "end must be after start")); + } + match p.get("frameRate") { + Some(Value::Null) => { + rs.frame_rate = None; + any = true; + } + Some(v) => { + let f = v.as_f64().filter(|f| *f > 0.0 && *f <= 1000.0).ok_or_else(|| bad(cmd, "frameRate: fps > 0 or null (comp rate)"))?; + rs.frame_rate = Some(FrameRate::from_f64(f)); + any = true; + } + None => {} + } + if let Some(b) = b_p(p, "motionBlur") { + rs.motion_blur = b; + any = true; + } + if let Some(b) = b_p(p, "skipExisting") { + rs.skip_existing = b; + any = true; + } + Ok(any) +} + +/// Apply Output Module parameters. +fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result { + let mut any = false; + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?; + om.set_format(f); + any = true; + } + if let Some(c) = str_p(p, "channels") { + om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() { + "rgb" => Channels::Rgb, + "rgba" | "rgbalpha" => Channels::Rgba, + _ => return Err(bad(cmd, "channels: rgb|rgba")), + }; + if om.channels == Channels::Rgba && !om.format.supports_alpha() { + return Err(bad(cmd, format!("{} has no alpha channel", om.format.label()))); + } + any = true; + } + if let Some(q) = f_p(p, "quality") { + om.quality = q.clamp(1.0, 100.0) as u8; + any = true; + } + if let Some(b) = f_p(p, "bitrate") { + om.bitrate_kbps = b.clamp(100.0, 500_000.0) as u32; + any = true; + } + if let Some(pp) = str_p(p, "proresProfile") { + om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; + any = true; + } + if let Some(a) = p.get("audio") { + om.audio = match a { + Value::Bool(true) => AudioOutput::On, + Value::Bool(false) => AudioOutput::Off, + Value::String(s) => match s.to_ascii_lowercase().as_str() { + "auto" => AudioOutput::Auto, + "on" => AudioOutput::On, + "off" => AudioOutput::Off, + _ => return Err(bad(cmd, "audio: auto|on|off")), + }, + _ => return Err(bad(cmd, "audio: auto|on|off")), + }; + any = true; + } + if let Some(r) = p.get("sampleRate").and_then(Value::as_u64) { + om.audio_sample_rate = (r as u32).clamp(8_000, 192_000); + any = true; + } + if let Some(l) = b_p(p, "loop") { + om.gif_loop = l; + any = true; + } + if let Some(o) = str_p(p, "output").or(str_p(p, "path")) { + om.output = o.to_string(); + if p.get("format").is_none() + && let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]")) + { + // `out.mov` with an H.264 module → ProRes, like picking a file type in Output To. + let keep = om.output.clone(); + om.set_format(f); + om.output = keep; + } + any = true; + } + Ok(any) +} + +/// A finished item that is edited goes back to Queued (AE re-queues on change). +fn requeue(it: &mut RenderQueueItem) { + if it.render && !matches!(it.status, RenderStatus::Rendering) { + it.status = RenderStatus::Queued; + } +} + +fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value { + let comp = s.project.comp(it.comp); + let name = s.project.item(it.comp).map(|i| i.name.clone()); + let (w, h) = comp.map(|c| it.settings.output_size(c)).map(|(w, h)| it.output.format.coded_size(w, h)).unwrap_or((0, 0)); + let frames = comp.map(|c| it.settings.frame_count(c)).unwrap_or(0); + let mut v = serde_json::to_value(it).unwrap_or(Value::Null); + if let Some(o) = v.as_object_mut() { + o.insert("index".into(), json!(index + 1)); + o.insert("compName".into(), json!(name)); + o.insert("statusLabel".into(), json!(it.status.label())); + o.insert("outputPath".into(), json!(s.resolve_output(it))); + o.insert("renderSettingsSummary".into(), json!(it.settings.summary())); + o.insert("outputModuleSummary".into(), json!(it.output.summary())); + o.insert("width".into(), json!(w)); + o.insert("height".into(), json!(h)); + o.insert("frames".into(), json!(frames)); + } + v +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?; + let mut it = RenderQueueItem::new(0, cid); + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?; + it.output = OutputModule::for_format(f); + } + apply_settings(&mut it.settings, p, "renderQueue.add")?; + let mut op = p.clone(); + if let Some(o) = op.as_object_mut() { + o.remove("format"); + } + apply_output(&mut it.output, &op, "renderQueue.add")?; + let id = s.edit("Add to Render Queue", None, |proj, _| { + it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; + let id = it.id; + proj.render_queue.push(it); + Ok(id) + })?; + let idx = s.project.render_queue.len() - 1; + let mut v = item_json(s, &s.project.render_queue[idx], idx); + v["item"] = json!(id); + Ok(v) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.remove")?; + s.edit("Remove from Render Queue", None, |proj, _| { + proj.render_queue.remove(i); + Ok(()) + })?; + Ok(json!({"remaining": s.project.render_queue.len()})) +} + +fn set_render(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.setRender")?; + let on = b_p(p, "render").unwrap_or(!s.project.render_queue[i].render); + s.edit("Render Queue: Render", None, |proj, _| { + let it = &mut proj.render_queue[i]; + it.render = on; + it.status = if on { RenderStatus::Queued } else { RenderStatus::Unqueued }; + Ok(()) + })?; + Ok(json!({"render": on})) +} + +fn set_render_settings(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.setRenderSettings")?; + let mut rs = s.project.render_queue[i].settings.clone(); + if !apply_settings(&mut rs, p, "renderQueue.setRenderSettings")? { + return Err(bad("renderQueue.setRenderSettings", "nothing to change")); + } + s.edit("Render Settings", None, |proj, _| { + let it = &mut proj.render_queue[i]; + it.settings = rs; + requeue(it); + Ok(()) + })?; + Ok(item_json(s, &s.project.render_queue[i], i)) +} + +fn set_output_module(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.setOutputModule")?; + let mut om = s.project.render_queue[i].output.clone(); + if !apply_output(&mut om, p, "renderQueue.setOutputModule")? { + return Err(bad("renderQueue.setOutputModule", "nothing to change")); + } + s.edit("Output Module Settings", None, |proj, _| { + let it = &mut proj.render_queue[i]; + it.output = om; + requeue(it); + Ok(()) + })?; + Ok(item_json(s, &s.project.render_queue[i], i)) +} + +fn set_output(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.setOutput")?; + let path = str_p(p, "path").or(str_p(p, "output")).ok_or_else(|| bad("renderQueue.setOutput", "pass `path` (file path or template)"))?; + let mut om = s.project.render_queue[i].output.clone(); + apply_output(&mut om, &json!({"output": path}), "renderQueue.setOutput")?; + s.edit("Output To", None, |proj, _| { + let it = &mut proj.render_queue[i]; + it.output = om; + requeue(it); + Ok(()) + })?; + Ok(item_json(s, &s.project.render_queue[i], i)) +} + +fn move_item(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.move")?; + let n = s.project.render_queue.len(); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("renderQueue.move", "pass `to` (1-based position)"))? as usize; + let to = to.clamp(1, n) - 1; + s.edit("Reorder Render Queue", None, |proj, _| { + let it = proj.render_queue.remove(i); + proj.render_queue.insert(to, it); + Ok(()) + })?; + Ok(json!({"index": to + 1})) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let i = item_index(s, p, "renderQueue.duplicate")?; + let id = s.edit("Duplicate Render Item", None, |proj, _| { + let mut it = proj.render_queue[i].clone(); + it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; + it.render = true; + it.status = RenderStatus::Queued; + it.started = None; + it.render_time = None; + it.last_output = None; + let id = it.id; + proj.render_queue.insert(i + 1, it); + Ok(id) + })?; + Ok(json!({"item": id, "index": i + 2})) +} + +fn render(s: &mut Session, p: &Value) -> Result { + let wait = b_p(p, "wait").unwrap_or(true); + let ids = s.start_render(wait).map_err(EngineError::Other)?; + let items: Vec = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect(); + Ok(json!({"rendering": s.is_rendering(), "items": items})) +} + +fn stop(s: &mut Session, _: &Value) -> Result { + Ok(json!({"stopped": s.stop_render()})) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.poll_render(); + let items: Vec = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect(); + Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()})) +} + +fn formats(s: &mut Session, _: &Value) -> Result { + let avail = s.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); + let v: Vec = OutputFormat::ALL + .iter() + .map(|f| { + json!({ + "id": format!("{f:?}"), + "label": f.label(), + "extension": f.extension(), + "sequence": f.is_sequence(), + "alpha": f.supports_alpha(), + "audio": f.supports_audio(), + "available": avail.contains(f), + }) + }) + .collect(); + Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::>()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "renderQueue.add", + "Add to Render Queue", + ["Composition"], + Some("Cmd+M"), + "{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, …renderSettings, …outputModule}", + can_add, + add + ), + cmd!("renderQueue.remove", "Remove from Render Queue", [], None, "{item?: id, index?: n}", has_items, remove), + cmd!("renderQueue.setRender", "Render Queue: Render Checkbox", [], None, "{item?|index?, render?: bool (toggles)}", has_items, set_render), + cmd!( + "renderQueue.setRenderSettings", + "Render Settings…", + [], + None, + "{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}", + has_items, + set_render_settings + ), + cmd!( + "renderQueue.setOutputModule", + "Output Module Settings…", + [], + None, + "{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}", + has_items, + set_output_module + ), + cmd!( + "renderQueue.setOutput", + "Output To…", + [], + None, + "{item?|index?, path: file path or template like [compName].[fileExtension]}", + has_items, + set_output + ), + cmd!("renderQueue.move", "Move in Render Queue", [], None, "{item?|index?, to: n (1-based)}", has_items, move_item), + cmd!("renderQueue.duplicate", "Duplicate Render Item", [], None, "{item?|index?}", has_items, duplicate), + cmd!("renderQueue.render", "Render", [], None, "{wait?: bool (default true; the UI renders in the background)}", has_queued, render), + cmd!("renderQueue.stop", "Stop Rendering", [], None, "{}", rendering, stop), + query!("renderQueue.list", "Render Queue Items", "{}", list), + query!("renderQueue.formats", "Output Formats", "{}", formats), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18f..e2725987a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod commands; pub mod demo; pub mod links; +pub mod render_queue; use std::sync::Arc; @@ -29,6 +30,7 @@ pub use effectcraft_keyframe as keyframe; pub use effectcraft_project as project; pub use effectcraft_render as render; pub use effectcraft_time as time; +pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -132,6 +134,10 @@ pub struct Session { pub footage: Arc, pub expr: Option>, pub importer: Option>, + /// Render Queue encoder (the export layer); `None` = export unavailable. + pub exporter: Option>, + /// The running (or finished, not yet polled) render. + pub render_job: Option, pub events: Vec, /// Commands executed: (id, params). pub journal: Vec<(String, Value)>, @@ -150,6 +156,8 @@ impl Default for Session { footage: Arc::new(NoFootage), expr: None, importer: None, + exporter: None, + render_job: None, events: vec![], journal: vec![], } @@ -308,6 +316,13 @@ impl Session { /// Replace the whole project (open/new), resetting history and state. pub fn replace_project(&mut self, mut p: Project, path: Option) { + if self.stop_render() + && let Some(mut job) = self.render_job.take() + { + // Let the worker notice the cancel; its updates refer to the old project. + job.wait(); + } + self.render_job = None; p.fix_next_id(); self.project = Arc::new(p); self.history = History::default(); @@ -322,6 +337,8 @@ impl Session { } } +#[cfg(test)] +mod rq_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs new file mode 100644 index 000000000..49241e13f --- /dev/null +++ b/crates/engine/src/render_queue.rs @@ -0,0 +1,344 @@ +//! Render Queue runtime: the [`Exporter`] hook (implemented by `effectcraft-export`, wired by +//! `effectcraft-host`), output path resolution, and the render job that works through the queued +//! items, in the background (desktop UI) or blocking (CLI, agents, tests). +//! +//! The queue itself (items, Render Settings, Output Modules, status) lives in the project +//! (`project.render_queue`) so it is saved with it. A running job owns a snapshot of the project; +//! it reports progress through [`JobShared`], and [`Session::poll_render`] copies item status / +//! start time / render time back into the project. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; +use effectcraft_project::{ItemId, Project}; +use effectcraft_render::{ExprHost, FootageSource}; +use serde::Serialize; + +use crate::{Event, Session}; + +/// One export handed to the [`Exporter`]. +pub struct ExportJob<'a> { + pub project: &'a Project, + pub footage: &'a dyn FootageSource, + pub expr: Option<&'a dyn ExprHost>, + pub item: &'a RenderQueueItem, + /// Resolved output path (templates expanded except the `#` frame-number run). + pub path: &'a str, +} + +/// What an export wrote. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct ExportResult { + pub path: String, + pub frames: u64, + pub width: u32, + pub height: u32, + pub bytes: u64, + pub seconds: f64, + pub audio: bool, +} + +/// Encodes render queue items (implemented by the export layer). +pub trait Exporter: Send + Sync { + /// Formats this build can write. + fn formats(&self) -> Vec; + /// Render and write one item (blocking). `progress(done, total)` returns `false` to cancel; + /// a cancelled export returns `Err("cancelled")`. + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result; +} + +/// The error string an [`Exporter`] returns when cancelled. +pub const CANCELLED: &str = "cancelled"; + +/// Live progress of a render job (serde for agents / the control channel). +#[derive(Clone, Debug, Default, Serialize)] +pub struct JobState { + /// Item being rendered. + pub current: Option, + /// Frames done / total of the current item. + pub done: u64, + pub total: u64, + /// Seconds since the job started / since the current item started. + pub elapsed: f64, + pub item_elapsed: f64, + /// Estimated seconds left for the current item. + pub remaining: Option, + /// Items rendered so far / items in this job. + pub items_done: usize, + pub items_total: usize, + pub finished: bool, +} + +/// Per-item status changes from the job thread, applied to the project by `poll_render`. +#[derive(Clone, Debug)] +pub enum ItemUpdate { + Started { id: u64, unix: u64 }, + Finished { id: u64, status: RenderStatus, seconds: f64, output: Option }, +} + +#[derive(Default)] +pub struct JobShared { + pub cancel: AtomicBool, + pub state: Mutex, + pub updates: Mutex>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl JobShared { + pub fn snapshot(&self) -> JobState { + lock(&self.state).clone() + } +} + +/// A running (or finished, not yet polled) render job. +pub struct RenderJob { + pub shared: Arc, + thread: Option>, +} + +impl RenderJob { + pub fn is_finished(&self) -> bool { + lock(&self.shared.state).finished + } + pub fn cancel(&self) { + self.shared.cancel.store(true, Ordering::Relaxed); + } + /// Block until the worker thread ends. + pub fn wait(&mut self) { + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// Everything the worker needs, detached from the session. +struct Work { + project: Arc, + footage: Arc, + expr: Option>, + exporter: Arc, + items: Vec<(RenderQueueItem, String)>, +} + +fn unix_now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn run_work(w: Work, shared: &JobShared) { + let t0 = std::time::Instant::now(); + { + let mut s = lock(&shared.state); + s.items_total = w.items.len(); + } + for (k, (item, path)) in w.items.iter().enumerate() { + if shared.cancel.load(Ordering::Relaxed) { + lock(&shared.updates).push(ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds: 0.0, output: None }); + continue; + } + let t1 = std::time::Instant::now(); + lock(&shared.updates).push(ItemUpdate::Started { id: item.id, unix: unix_now() }); + { + let mut s = lock(&shared.state); + s.current = Some(item.id); + s.done = 0; + s.total = 0; + s.items_done = k; + s.remaining = None; + } + let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item, path }; + let r = w.exporter.export(&job, &mut |done, total| { + let mut s = lock(&shared.state); + s.done = done; + s.total = total; + s.elapsed = t0.elapsed().as_secs_f64(); + s.item_elapsed = t1.elapsed().as_secs_f64(); + s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64); + !shared.cancel.load(Ordering::Relaxed) + }); + let seconds = t1.elapsed().as_secs_f64(); + let up = match r { + Ok(res) => ItemUpdate::Finished { id: item.id, status: RenderStatus::Done, seconds, output: Some(res.path) }, + Err(e) if e == CANCELLED => ItemUpdate::Finished { id: item.id, status: RenderStatus::UserStopped, seconds, output: None }, + Err(e) => { + log::warn!("render queue: item {} failed: {e}", item.id); + ItemUpdate::Finished { id: item.id, status: RenderStatus::Failed(e), seconds, output: None } + } + }; + lock(&shared.updates).push(up); + } + let mut s = lock(&shared.state); + s.items_done = w.items.len(); + s.current = None; + s.elapsed = t0.elapsed().as_secs_f64(); + s.finished = true; +} + +impl Session { + /// The project's name for `[projectName]` (file stem, or "Untitled Project"). + pub fn project_name(&self) -> String { + self.path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_stem()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled Project".into()) + } + + /// The output path of a queue item: template tokens expanded, relative paths resolved against + /// the project's folder (or the working directory). `None` when the comp is gone or the + /// output is empty ("Needs Output"). + pub fn resolve_output(&self, item: &RenderQueueItem) -> Option { + let comp = self.project.comp(item.comp)?; + let name = self.project.item(item.comp).map(|i| i.name.clone()).unwrap_or_default(); + let tpl = item.output.output.trim(); + if tpl.is_empty() { + return None; + } + let (w, h) = item.settings.output_size(comp); + let (w, h) = item.output.format.coded_size(w, h); + let rate = item.settings.rate(comp); + let n = item.settings.frame_count(comp) as i64; + let f0 = item.settings.first_frame(comp); + let pname = self.project_name(); + let vars = + TemplateVars { comp_name: &name, project_name: &pname, width: w, height: h, frame_rate: rate.as_f64(), start_frame: f0, end_frame: f0 + n - 1 }; + let p = expand_template(tpl, item.output.format, &vars); + let path = std::path::Path::new(&p); + if path.is_absolute() { + return Some(p); + } + let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()); + let base = base.or_else(|| std::env::current_dir().ok())?; + Some(base.join(path).to_string_lossy().to_string()) + } + + /// Whether a render job is running. + pub fn is_rendering(&self) -> bool { + self.render_job.as_ref().is_some_and(|j| !j.is_finished()) + } + + /// Live job progress. + pub fn render_progress(&self) -> Option { + self.render_job.as_ref().map(|j| j.shared.snapshot()) + } + + /// Start rendering every queued item. `wait`: block until done (otherwise a background thread + /// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered. + pub fn start_render(&mut self, wait: bool) -> Result, String> { + if self.is_rendering() { + return Err("a render is already in progress".into()); + } + self.poll_render(); + let exporter = self.exporter.clone().ok_or("export is not available in this build")?; + let formats = exporter.formats(); + let mut items = Vec::new(); + let mut needs_output = Vec::new(); + for it in self.project.render_queue.iter().filter(|i| i.is_queued()) { + match self.resolve_output(it) { + Some(p) if formats.contains(&it.output.format) => items.push((it.clone(), p)), + Some(_) => needs_output.push((it.id, RenderStatus::Failed(format!("{} export is not available", it.output.format.label())))), + None if self.project.comp(it.comp).is_none() => needs_output.push((it.id, RenderStatus::Failed("composition missing".into()))), + None => needs_output.push((it.id, RenderStatus::NeedsOutput)), + } + } + if !needs_output.is_empty() { + let p = Arc::make_mut(&mut self.project); + for (id, st) in needs_output { + if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == id) { + i.status = st; + } + } + self.bump(); + } + if items.is_empty() { + return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into()); + } + let ids: Vec = items.iter().map(|(i, _)| i.id).collect(); + let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items }; + let shared = Arc::new(JobShared::default()); + if wait { + run_work(work, &shared); + self.render_job = Some(RenderJob { shared, thread: None }); + } else { + let sh = shared.clone(); + let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?; + self.render_job = Some(RenderJob { shared, thread: Some(thread) }); + } + self.poll_render(); + Ok(ids) + } + + /// Stop the running render (the current item becomes "User Stopped"). + pub fn stop_render(&mut self) -> bool { + match &self.render_job { + Some(j) if !j.is_finished() => { + j.cancel(); + true + } + _ => false, + } + } + + /// Apply job updates to the queue items; drop the job once it finished. Returns whether + /// anything changed. Frontends call this every frame while rendering. + pub fn poll_render(&mut self) -> bool { + let Some(job) = &self.render_job else { return false }; + let updates = std::mem::take(&mut *lock(&job.shared.updates)); + let finished = job.is_finished(); + let changed = !updates.is_empty(); + if changed { + let p = Arc::make_mut(&mut self.project); + for u in &updates { + match u { + ItemUpdate::Started { id, unix } => { + if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) { + i.status = RenderStatus::Rendering; + i.started = Some(*unix); + i.render_time = None; + } + } + ItemUpdate::Finished { id, status, seconds, output } => { + if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == *id) { + i.status = status.clone(); + i.render_time = Some(*seconds); + if output.is_some() { + i.last_output = output.clone(); + } + } + } + } + } + self.bump(); + } + if finished { + let mut job = self.render_job.take().expect("job"); + if let Some(t) = job.thread.take() { + let _ = t.join(); + } + let st = job.shared.snapshot(); + let failed = updates.iter().filter(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::Failed(_), .. })).count(); + let stopped = updates.iter().any(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::UserStopped, .. })); + let msg = if failed > 0 { + format!("Render finished with {failed} failed item(s)") + } else if stopped { + "Render stopped".to_string() + } else { + format!("Rendered {} item(s) in {:.1} s", st.items_total, st.elapsed) + }; + self.events.push(Event::Toast { message: msg, error: failed > 0 }); + } + changed || finished + } + + /// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel. + pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option { + if p.get("comp").is_some() { + return crate::commands::comp_id(self, p).ok(); + } + let sel = self.state.project_selection.iter().copied().find(|i| self.project.comp(*i).is_some()); + self.active_comp_id().or(sel) + } +} diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs new file mode 100644 index 000000000..c4aa08b8a --- /dev/null +++ b/crates/engine/src/rq_tests.rs @@ -0,0 +1,164 @@ +//! Render Queue commands against mock exporters (real encoding is tested in `effectcraft-export` +//! and end to end in `effectcraft-host`). + +use std::sync::{Arc, Mutex}; + +use effectcraft_project::render_queue::{OutputFormat, RenderQuality, RenderStatus}; +use serde_json::json; + +use crate::render_queue::CANCELLED; +use crate::{Event, ExportJob, ExportResult, Exporter, Session}; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +/// Records exports; fails items whose path contains "fail"; honours cancellation. +struct MockExporter { + log: Mutex>, +} + +impl Exporter for MockExporter { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + self.log.lock().unwrap().push(job.path.to_string()); + if job.path.contains("fail") { + return Err("boom".into()); + } + let comp = job.project.comp(job.item.comp).ok_or("no comp")?; + let n = job.item.settings.frame_count(comp); + for k in 0..=n { + if !progress(k, n) { + return Err(CANCELLED.into()); + } + } + Ok(ExportResult { path: job.path.to_string(), frames: n, ..Default::default() }) + } +} + +fn rq_session() -> (Session, Arc) { + let mut s = demo(); + let ex = Arc::new(MockExporter { log: Default::default() }); + s.exporter = Some(ex.clone()); + (s, ex) +} + +#[test] +fn add_configure_render() { + let (mut s, ex) = rq_session(); + assert!(s.is_enabled("renderQueue.add")); + assert!(!s.is_enabled("renderQueue.render")); + let a = s.execute("renderQueue.add", json!({})).unwrap(); + let name = s.project.item(s.active_comp_id().unwrap()).unwrap().name.clone(); + assert_eq!(a["index"], 1); + assert_eq!(a["statusLabel"], "Queued"); + assert!(a["outputPath"].as_str().unwrap().ends_with(&format!("{name}.mp4")), "{a}"); + let id = a["item"].as_u64().unwrap(); + // Render Settings / Output Module / Output To. + let r = s + .execute("renderQueue.setRenderSettings", json!({"item": id, "resolution": "half", "timeSpan": "custom", "start": 1.0, "end": 2.0, "frameRate": 10})) + .unwrap(); + assert_eq!(r["frames"], 10); + assert_eq!(r["width"], 960); + let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap(); + assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}"); + assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err()); + let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap(); + assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes"); + assert_eq!(r["outputPath"], "/tmp/rq/out.mov"); + // A second item that fails, a third that is unqueued. + let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap(); + let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap(); + s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap(); + assert_eq!(s.project.render_queue[2].status.label(), "Unqueued"); + let r = s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2); + assert_eq!(*ex.log.lock().unwrap(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]); + let q = &s.project.render_queue; + assert_eq!(q[0].status.label(), "Done"); + assert!(q[0].started.is_some() && q[0].render_time.is_some()); + assert_eq!(q[0].last_output.as_deref(), Some("/tmp/rq/out.mov")); + assert_eq!(q[1].status, RenderStatus::Failed("boom".into())); + assert_eq!(q[2].status.label(), "Unqueued"); + assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. }))); + // Reorder, duplicate, remove, undo. + s.execute("renderQueue.move", json!({"item": b["item"], "to": 1})).unwrap(); + assert_eq!(s.project.render_queue[0].id, b["item"].as_u64().unwrap()); + let d = s.execute("renderQueue.duplicate", json!({"index": 2})).unwrap(); + assert_eq!(s.project.render_queue.len(), 4); + assert_eq!(s.project.render_queue[2].id, d["item"].as_u64().unwrap()); + assert_eq!(s.project.render_queue[2].status.label(), "Queued"); + s.execute("renderQueue.remove", json!({"index": 3})).unwrap(); + assert_eq!(s.project.render_queue.len(), 3); + s.undo(); + assert_eq!(s.project.render_queue.len(), 4); +} + +#[test] +fn persists_in_project_file() { + let (mut s, _) = rq_session(); + s.execute("renderQueue.add", json!({"format": "tiff", "output": "[compName]/[compName]_[####].tif", "quality": "draft"})).unwrap(); + let json = s.project.to_json(); + let p = effectcraft_project::Project::from_json(&json).unwrap(); + assert_eq!(p.render_queue, s.project.render_queue); + assert_eq!(p.render_queue[0].settings.quality, RenderQuality::Draft); + // Projects without a queue still load. + let old = json.replace("\"render_queue\"", "\"_ignored\""); + assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty()); +} + +#[test] +fn background_render_and_stop() { + struct Slow; + impl Exporter for Slow { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, _: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + for k in 0..10_000 { + std::thread::sleep(std::time::Duration::from_millis(2)); + if !progress(k, 10_000) { + return Err(CANCELLED.into()); + } + } + Ok(Default::default()) + } + } + let mut s = demo(); + s.exporter = Some(Arc::new(Slow)); + s.execute("renderQueue.add", json!({"output": "/tmp/a.mp4"})).unwrap(); + s.execute("renderQueue.add", json!({"output": "/tmp/b.mp4"})).unwrap(); + s.execute("renderQueue.render", json!({"wait": false})).unwrap(); + assert!(s.is_rendering()); + assert!(!s.is_enabled("renderQueue.add"), "queue is locked while rendering"); + let t0 = std::time::Instant::now(); + while s.render_progress().is_some_and(|p| p.done < 3) { + assert!(t0.elapsed().as_secs() < 20); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + s.poll_render(); + assert_eq!(s.project.render_queue[0].status.label(), "Rendering"); + s.execute("renderQueue.stop", json!({})).unwrap(); + while s.is_rendering() { + assert!(t0.elapsed().as_secs() < 20); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + s.poll_render(); + assert!(s.render_job.is_none()); + assert_eq!(s.project.render_queue[0].status.label(), "User Stopped"); + assert_eq!(s.project.render_queue[1].status.label(), "User Stopped"); +} + +#[test] +fn without_exporter() { + let mut s = demo(); + s.execute("renderQueue.add", json!({})).unwrap(); + assert!(!s.is_enabled("renderQueue.render")); + let f = s.execute("renderQueue.formats", json!({})).unwrap(); + assert_eq!(f["formats"].as_array().unwrap().len(), 7); + assert_eq!(f["formats"][0]["available"], false); +} diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml new file mode 100644 index 000000000..5534fd02b --- /dev/null +++ b/crates/export/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "effectcraft-export" +description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-time = { workspace = true } +effectcraft-project = { workspace = true } +effectcraft-raster = { workspace = true } +effectcraft-render = { workspace = true } +filmcraft-h264enc = { workspace = true } +filmcraft-prores = { workspace = true } +filmcraft-aac = { workspace = true } +filmcraft-isobmff = { workspace = true } +image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] } +gif = { workspace = true } +rayon = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +effectcraft-media = { workspace = true } +effectcraft-color = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs new file mode 100644 index 000000000..e00bb93c5 --- /dev/null +++ b/crates/export/src/encode.rs @@ -0,0 +1,362 @@ +//! Movie export: FilmCraft H.264 → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by +//! FilmCraft's ISO BMFF / QuickTime writer. + +use std::io::{BufWriter, Write}; + +use effectcraft_project::Comp; +use effectcraft_project::render_queue::{Channels, OutputFormat, ProResProfile}; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use rayon::prelude::*; + +use crate::{ExportError, Job, Report, Result, State, batch_size, render_frame, rgba8, wants_audio}; + +fn enc(e: impl std::fmt::Display) -> ExportError { + ExportError::Encode(e.to_string()) +} +fn mux_err(e: impl std::fmt::Display) -> ExportError { + ExportError::Io(e.to_string()) +} + +struct Packet { + data: Vec, + key: bool, + /// pts − dts in the track timescale. + cto: i32, +} + +trait VideoEncoder { + fn sample_entry(&self) -> SampleEntry; + fn encode(&mut self, rgba: &[u8], index: u64) -> Result>; + fn flush(&mut self) -> Result>; + /// Edit-list media start (B-frame delay) in the track timescale. + fn media_start(&self) -> Option { + None + } +} + +// ---------------------------------------------------------------- H.264 + +struct H264 { + enc: filmcraft_h264enc::Encoder, + w: u32, + h: u32, + rate: FrameRate, + y: Vec, + u: Vec, + v: Vec, +} + +impl H264 { + fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result { + let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); + cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + cfg.aud = false; + cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32; + let kbps = kbps.max(100); + cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; + let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?; + Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] }) + } + fn packets(&self, ps: Vec) -> Vec { + ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect() + } +} + +impl VideoEncoder for H264 { + fn sample_entry(&self) -> SampleEntry { + let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| { + let (sps, pps) = self.enc.sps_pps(); + filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) + }); + SampleEntry::avc(cfg, self.w as u16, self.h as u16) + } + fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { + rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v); + let cw = (self.w as usize).div_ceil(2); + let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w as usize, uv_stride: cw }; + let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?; + Ok(self.packets(ps)) + } + fn flush(&mut self) -> Result> { + let ps = self.enc.flush(); + Ok(self.packets(ps)) + } + fn media_start(&self) -> Option { + (self.enc.delay() > 0).then_some(self.rate.den) + } +} + +/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored). +pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec, u: &mut Vec, v: &mut Vec) { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + y.resize(w * h, 0); + u.resize(cw * ch, 0); + v.resize(cw * ch, 0); + y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { + let rows = yr.len() / w; + let mut acc = vec![(0f32, 0f32, 0f32); cw]; + for dy in 0..rows { + let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4]; + for x in 0..w { + let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0); + let l = 0.2126 * r + 0.7152 * g + 0.0722 * b; + yr[dy * w + x] = (16.0 + 219.0 * l).round().clamp(1.0, 254.0) as u8; + let a = &mut acc[x / 2]; + a.0 += (b - l) / 1.8556; + a.1 += (r - l) / 1.5748; + a.2 += 1.0; + } + } + for (cx, a) in acc.iter().enumerate() { + ur[cx] = (128.0 + 224.0 * a.0 / a.2).round().clamp(1.0, 254.0) as u8; + vr[cx] = (128.0 + 224.0 * a.1 / a.2).round().clamp(1.0, 254.0) as u8; + } + }); +} + +// ---------------------------------------------------------------- ProRes + +struct ProRes { + enc: filmcraft_prores::Encoder, + profile: filmcraft_prores::Profile, + w: u32, + h: u32, + alpha: bool, +} + +fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { + use filmcraft_prores::Profile; + match p { + ProResProfile::Proxy => Profile::Proxy, + ProResProfile::Lt => Profile::Lt, + ProResProfile::Standard => Profile::Standard, + ProResProfile::Hq => Profile::Hq, + ProResProfile::P4444 => Profile::P4444, + ProResProfile::P4444Xq => Profile::P4444Xq, + } +} + +impl ProRes { + fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes { + // Alpha needs a 4444 flavour. + let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile }; + let profile = prores_profile(profile); + let alpha = channels == Channels::Rgba; + let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h); + cfg.encode_alpha = alpha; + ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha } + } + fn is_444(&self) -> bool { + matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq) + } +} + +impl VideoEncoder for ProRes { + fn sample_entry(&self) -> SampleEntry { + SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16) + } + fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { + let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 }; + let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha); + rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr); + if let Some(a) = fr.alpha.as_mut() { + for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) { + *d = ((s[3] as u32 * 1023 + 127) / 255) as u16; + } + } + let data = self.enc.encode(&fr).map_err(enc)?; + Ok(vec![Packet { data, key: true, cto: 0 }]) + } + fn flush(&mut self) -> Result> { + Ok(vec![]) + } +} + +/// BT.709 limited-range 10-bit 4:2:2 (horizontal chroma average) or 4:4:4 from RGBA8. +fn rgba_to_yuv_10(rgba: &[u8], w: usize, h: usize, full_chroma: bool, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { + let cw = if full_chroma { w } else { w.div_ceil(2) }; + debug_assert!(y.len() >= w * h && rgba.len() >= w * h * 4); + y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { + let src = &rgba[row * w * 4..(row + 1) * w * 4]; + let mut us = vec![0f32; w]; + let mut vs = vec![0f32; w]; + for x in 0..w { + let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0); + let l = 0.2126 * r + 0.7152 * g + 0.0722 * b; + yr[x] = (64.0 + 876.0 * l).round().clamp(4.0, 1019.0) as u16; + us[x] = (b - l) / 1.8556; + vs[x] = (r - l) / 1.5748; + } + for cx in 0..cw { + let (u, v) = if full_chroma { + (us[cx], vs[cx]) + } else { + let (a, b2) = (cx * 2, (cx * 2 + 1).min(w - 1)); + ((us[a] + us[b2]) * 0.5, (vs[a] + vs[b2]) * 0.5) + }; + cbr[cx] = (512.0 + 896.0 * u).round().clamp(4.0, 1019.0) as u16; + crr[cx] = (512.0 + 896.0 * v).round().clamp(4.0, 1019.0) as u16; + } + }); +} + +// ---------------------------------------------------------------- audio + +enum Audio { + Aac(Box), + Pcm, +} + +const AUDIO_CHANNELS: u32 = 2; + +fn deinterleave(buf: &[f32]) -> [Vec; 2] { + let l = buf.chunks_exact(2).map(|p| p[0]).collect(); + let r = buf.chunks_exact(2).map(|p| p[1]).collect(); + [l, r] +} + +// ---------------------------------------------------------------- the movie + +type Writer = Mp4Writer>; + +pub(crate) fn movie(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { + let rate = job.settings.rate(comp); + let fmt = job.output.format; + let channels = if fmt == OutputFormat::ProRes { job.output.channels } else { Channels::Rgb }; + let mut venc: Box = match fmt { + OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?), + _ => Box::new(ProRes::new(w, h, job.output.prores_profile, channels)), + }; + let brand = if fmt == OutputFormat::H264 { Brand::Mp4 } else { Brand::Mov }; + let batch = batch_size(); + let render = |k: u64| -> Vec { rgba8(&render_frame(job, comp, k), comp, channels, w, h) }; + + // Encode the first batch before creating tracks: encoders finalise their config on frame 1. + let first_end = batch.min(st.total); + let first: Vec> = (0..first_end).into_par_iter().map(render).collect(); + let mut pending = Vec::new(); + for (k, px) in first.iter().enumerate() { + pending.extend(venc.encode(px, k as u64)?); + } + drop(first); + st.advance(first_end)?; + + let file = std::fs::File::create(job.path).map_err(mux_err)?; + let opts = WriterOptions::new(brand); + let movie_ts = opts.movie_timescale.max(1) as i128; + let mut mux: Writer = Mp4Writer::new(BufWriter::new(file), opts).map_err(mux_err)?; + let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32); + if let Some(start) = venc.media_start() { + // An explicit edit: the convenience `media_start` edit would subtract the B-frame delay + // from the media duration and drop the last frame. + let frames_ts = st.total as i128 * rate.den as i128; + let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128; + vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; + } + let vt = mux.add_track(vcfg).map_err(mux_err)?; + let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); + let with_audio = wants_audio(job); + let mut audio = None; + if with_audio { + let (a, entry, start) = if brand == Brand::Mp4 { + let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, AUDIO_CHANNELS as usize, 320_000)).map_err(enc)?; + let entry = SampleEntry::aac(e.audio_specific_config(), AUDIO_CHANNELS, sr); + let priming = e.priming_samples() as i64; + (Audio::Aac(Box::new(e)), entry, Some(priming)) + } else { + let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: AUDIO_CHANNELS, sample_rate: sr as f64 }; + (Audio::Pcm, SampleEntry::pcm(pcm), None) + }; + let mut c = TrackConfig::new(entry, sr); + c.media_start = start; + let at = mux.add_track(c).map_err(mux_err)?; + audio = Some((a, at)); + } + let (span_start, span_end) = job.settings.span(comp); + let mut cursor = span_start.to_units_floor(sr as i64); + + let write_video = |mux: &mut Writer, ps: Vec| -> Result<()> { + for p in ps { + mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?; + } + Ok(()) + }; + let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> { + let Some((a, at)) = audio.as_mut() else { return Ok(()) }; + let end = until.to_units_floor(sr as i64); + if end <= *cursor { + return Ok(()); + } + let n = (end - *cursor) as usize; + let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); + let buf = effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, sr); + *cursor = end; + match a { + Audio::Aac(e) => { + let planar = deinterleave(&buf); + let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect(); + for au in e.encode(&refs) { + mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + } + } + Audio::Pcm => { + let mut pcm = Vec::with_capacity(n * 4); + for s in buf { + pcm.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0).round() as i16).to_le_bytes()); + } + mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + } + } + Ok(()) + }; + let total = st.total; + let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } }; + write_video(&mut mux, pending)?; + write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?; + let mut i = first_end; + while i < total { + let end = (i + batch).min(total); + let frames: Vec> = (i..end).into_par_iter().map(render).collect(); + for (k, px) in frames.iter().enumerate() { + let ps = venc.encode(px, i + k as u64)?; + write_video(&mut mux, ps)?; + } + write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?; + st.advance(end - i)?; + i = end; + } + write_video(&mut mux, venc.flush()?)?; + write_audio_until(&mut mux, span_end, &mut cursor)?; + if let Some((Audio::Aac(e), at)) = audio.as_mut() { + for au in e.flush() { + mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + } + } + let mut out = mux.finish().map_err(mux_err)?; + out.flush().map_err(mux_err)?; + drop(out); + let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0); + Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yuv_of_primaries() { + let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 255]; + let (mut y, mut u, mut v) = (vec![], vec![], vec![]); + rgba_to_yuv420(&rgba, 2, 2, &mut y, &mut u, &mut v); + assert_eq!(y[0], 235); + assert_eq!(y[1], 16); + assert_eq!((u.len(), v.len()), (1, 1)); + let (mut y10, mut cb, mut cr) = (vec![0u16; 4], vec![0u16; 4], vec![0u16; 4]); + rgba_to_yuv_10(&rgba, 2, 2, true, &mut y10, &mut cb, &mut cr); + assert_eq!(y10[0], 940); + assert_eq!(y10[1], 64); + assert_eq!(cb[0], 512); + assert!(cr[2] > 900, "red has high Cr: {}", cr[2]); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs new file mode 100644 index 000000000..051183a6f --- /dev/null +++ b/crates/export/src/lib.rs @@ -0,0 +1,350 @@ +//! Render Queue jobs: render a composition's time span and write it through an output module. +//! +//! | Format | Container / files | Video | Alpha | Audio | +//! |---|---|---|---|---| +//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), stereo | +//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | 16-bit PCM, stereo | +//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — | +//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — | +//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — | +//! +//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to +//! the encoder in order. Progress is reported after each batch through a callback that can cancel +//! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates +//! (plan ADR 0001); nothing outside this crate touches them. +//! +//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the +//! output is a single pixel wide/high). + +mod encode; + +use std::path::Path; +use std::time::Instant; + +use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderQuality, RenderSettings, sequence_path}; +use effectcraft_project::{Comp, ItemId, Project}; +use effectcraft_raster::Image; +use effectcraft_render::{ExprHost, FootageSource, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use rayon::prelude::*; + +pub use effectcraft_project::render_queue; + +#[derive(Debug, thiserror::Error)] +pub enum ExportError { + #[error("composition not found")] + NoComp, + #[error("cancelled")] + Cancelled, + #[error("i/o: {0}")] + Io(String), + #[error("encode: {0}")] + Encode(String), + #[error("{0}")] + Unsupported(String), +} + +pub type Result = std::result::Result; + +fn io(e: impl std::fmt::Display) -> ExportError { + ExportError::Io(e.to_string()) +} + +/// One export: a comp, its Render Settings and Output Module, and the resolved output path. +pub struct Job<'a> { + pub project: &'a Project, + pub footage: &'a dyn FootageSource, + pub expr: Option<&'a dyn ExprHost>, + pub comp: ItemId, + pub settings: &'a RenderSettings, + pub output: &'a OutputModule, + /// Output file. For image sequences a run of `#` (or `[#####]`) becomes the frame number; a + /// path without one gets `_NNNNN` before the extension. + pub path: &'a str, +} + +/// Progress after a batch of frames. +#[derive(Clone, Copy, Debug, Default)] +pub struct Progress { + pub done: u64, + pub total: u64, + pub elapsed: f64, +} + +impl Progress { + pub fn fraction(&self) -> f64 { + if self.total == 0 { 0.0 } else { self.done as f64 / self.total as f64 } + } + /// Estimated seconds remaining. + pub fn remaining(&self) -> Option { + (self.done > 0).then(|| self.elapsed / self.done as f64 * self.total.saturating_sub(self.done) as f64) + } +} + +/// What an export produced. +#[derive(Clone, Debug, PartialEq)] +pub struct Report { + /// The movie file, or the first file of a sequence. + pub path: String, + pub frames: u64, + pub width: u32, + pub height: u32, + pub seconds: f64, + pub bytes: u64, + pub audio: bool, +} + +/// Formats this build can write (all of them; kept as the hook for optional encoders). +pub fn available_formats() -> Vec { + OutputFormat::ALL.to_vec() +} + +/// The size of the written frames for a job (H.264 rounds to even). +pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) { + let (w, h) = settings.output_size(comp); + output.format.coded_size(w, h) +} + +/// Whether the job writes an audio track. +pub fn wants_audio(job: &Job) -> bool { + job.output.format.supports_audio() + && match job.output.audio { + AudioOutput::On => true, + AudioOutput::Off => false, + AudioOutput::Auto => effectcraft_render::audio::comp_has_audio(job.project, job.comp), + } +} + +/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels. +pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { + let comp = job.project.comp(job.comp).ok_or(ExportError::NoComp)?; + let t0 = Instant::now(); + let total = job.settings.frame_count(comp); + let mut st = State { t0, total, done: 0, progress }; + st.advance(0)?; + if let Some(dir) = Path::new(job.path).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(io)?; + } + let (w, h) = output_size(comp, job.settings, job.output); + let mut report = match job.output.format { + OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?, + OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?, + f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?, + f => return Err(ExportError::Unsupported(f.label().into())), + }; + report.seconds = t0.elapsed().as_secs_f64(); + report.frames = total; + (report.width, report.height) = (w, h); + Ok(report) +} + +pub(crate) struct State<'p> { + t0: Instant, + total: u64, + done: u64, + progress: &'p mut dyn FnMut(&Progress) -> bool, +} + +impl State<'_> { + fn snapshot(&self) -> Progress { + Progress { done: self.done, total: self.total, elapsed: self.t0.elapsed().as_secs_f64() } + } + /// Count `n` more frames; `Err(Cancelled)` when the callback says stop. + pub(crate) fn advance(&mut self, n: u64) -> Result<()> { + self.done += n; + let p = self.snapshot(); + if (self.progress)(&p) { Ok(()) } else { Err(ExportError::Cancelled) } + } +} + +/// Frames per parallel batch. +pub(crate) fn batch_size() -> u64 { + rayon::current_num_threads().clamp(2, 16) as u64 +} + +/// Render output frame `i` of the job at the settings' resolution. +pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { + let t: Tick = job.settings.frame_time(comp, i); + let opts = RenderOpts { + scale: job.settings.resolution.clamp(0.01, 4.0), + motion_blur: job.settings.motion_blur, + guides: false, + draft: job.settings.quality == RenderQuality::Draft, + }; + let mut r = Renderer::new(job.project, job.footage, opts); + r.expr = job.expr; + r.comp_frame(job.comp, t) +} + +/// 8-bit RGBA of the requested channels (RGB: over the comp background, opaque; RGBA: straight), +/// cropped/padded to `w`×`h`. +pub(crate) fn rgba8(img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let px = match channels { + Channels::Rgb => img.to_rgba8_over(comp.background), + Channels::Rgba => img.to_rgba8(), + }; + fit(px, img.width, img.height, w, h) +} + +fn fit(px: Vec, iw: u32, ih: u32, w: u32, h: u32) -> Vec { + if iw == w && ih == h { + return px; + } + let mut out = vec![0u8; (w * h * 4) as usize]; + for y in 0..h.min(ih) as usize { + let n = w.min(iw) as usize * 4; + out[y * w as usize * 4..y * w as usize * 4 + n].copy_from_slice(&px[y * iw as usize * 4..y * iw as usize * 4 + n]); + } + out +} + +/// The comp frame number of output frame 0 (sequence numbering follows comp frames, like AE). +fn first_frame_number(job: &Job, comp: &Comp) -> i64 { + job.settings.first_frame(comp) +} + +fn sequence(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { + let f0 = first_frame_number(job, comp); + let fmt = job.output.format; + let channels = if fmt.supports_alpha() { job.output.channels } else { Channels::Rgb }; + let batch = batch_size(); + let mut bytes = 0u64; + let mut i = 0; + while i < st.total { + let end = (i + batch).min(st.total); + let written: Vec> = (i..end) + .into_par_iter() + .map(|k| { + let path = sequence_path(job.path, f0 + k as i64); + if job.settings.skip_existing && Path::new(&path).exists() { + return Ok(0); + } + let img = render_frame(job, comp, k); + write_still(&path, fmt, &img, comp, channels, w, h, job.output.quality)?; + Ok(std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)) + }) + .collect(); + for r in written { + bytes += r?; + } + st.advance(end - i)?; + i = end; + } + Ok(Report { path: sequence_path(job.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false }) +} + +#[allow(clippy::too_many_arguments)] +fn write_still(path: &str, fmt: OutputFormat, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, quality: u8) -> Result<()> { + use image::{ExtendedColorType, ImageEncoder, ImageFormat}; + let file = || std::fs::File::create(path).map(std::io::BufWriter::new).map_err(io); + match fmt { + OutputFormat::ExrSequence => { + // Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background. + let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + let mut data = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] }; + let (c, a) = match channels { + Channels::Rgba => { + let a = p[3].clamp(0.0, 1.0); + let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 }; + ([s(p[0]), s(p[1]), s(p[2])], a) + } + Channels::Rgb => { + let k = 1.0 - p[3].clamp(0.0, 1.0); + let c = |i: usize| lin((p[i] + comp.background[i] * k).clamp(0.0, 1.0)); + ([c(0), c(1), c(2)], 1.0) + } + }; + data.extend_from_slice(&[c[0], c[1], c[2], a]); + } + } + let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?; + image::DynamicImage::ImageRgba32F(buf).save_with_format(path, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) + } + OutputFormat::JpegSequence => { + let px = rgba8(img, comp, Channels::Rgb, w, h); + let rgb: Vec = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + image::codecs::jpeg::JpegEncoder::new_with_quality(file()?, quality.clamp(1, 100)) + .write_image(&rgb, w, h, ExtendedColorType::Rgb8) + .map_err(|e| ExportError::Encode(e.to_string())) + } + OutputFormat::PngSequence | OutputFormat::TiffSequence => { + let px = rgba8(img, comp, channels, w, h); + let (data, ct) = match channels { + Channels::Rgba => (px, ExtendedColorType::Rgba8), + Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), + }; + if fmt == OutputFormat::PngSequence { + image::codecs::png::PngEncoder::new(file()?).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + } else { + // The TIFF encoder needs Seek. + let f = std::fs::File::create(path).map_err(io)?; + image::codecs::tiff::TiffEncoder::new(std::io::BufWriter::new(f)).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + } + } + f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))), + } +} + +fn gif_export(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result { + if w > u16::MAX as u32 || h > u16::MAX as u32 { + return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into())); + } + let file = std::fs::File::create(job.path).map_err(io)?; + let mut enc = gif::Encoder::new(std::io::BufWriter::new(file), w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?; + enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?; + let rate = job.settings.rate(comp); + // GIF delays are in 1/100 s: carry the rounding error so long animations keep their timing. + let cs_per_frame = 100.0 * rate.den as f64 / rate.num as f64; + let mut clock = 0.0f64; + let mut emitted = 0u64; + let batch = batch_size(); + let mut i = 0; + while i < st.total { + let end = (i + batch).min(st.total); + let frames: Vec> = (i..end) + .into_par_iter() + .map(|k| { + let img = render_frame(job, comp, k); + let mut px = rgba8(&img, comp, job.output.channels, w, h); + gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) + }) + .collect(); + for mut f in frames { + clock += cs_per_frame; + let d = (clock.round() as u64).saturating_sub(emitted).max(1); + emitted += d; + f.delay = d.min(u16::MAX as u64) as u16; + if job.output.channels == Channels::Rgba { + f.dispose = gif::DisposalMethod::Background; + } + enc.write_frame(&f).map_err(|e| ExportError::Encode(e.to_string()))?; + } + st.advance(end - i)?; + i = end; + } + drop(enc); + let bytes = std::fs::metadata(job.path).map(|m| m.len()).unwrap_or(0); + Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_crops_and_pads() { + let px: Vec = (0..3 * 3 * 4).map(|v| v as u8).collect(); + let c = fit(px.clone(), 3, 3, 2, 2); + assert_eq!(c.len(), 16); + assert_eq!(&c[0..8], &px[0..8]); + assert_eq!(&c[8..16], &px[12..20]); + let p = fit(px, 3, 3, 4, 4); + assert_eq!(p.len(), 64); + assert_eq!(&p[12..16], &[0, 0, 0, 0]); + assert_eq!(OutputFormat::H264.coded_size(1, 101), (2, 100)); + assert_eq!(OutputFormat::Gif.coded_size(1, 101), (1, 101)); + } +} diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs new file mode 100644 index 000000000..4ee077d9b --- /dev/null +++ b/crates/export/tests/roundtrip.rs @@ -0,0 +1,365 @@ +//! Encode a short comp to every output format and decode it back: through EffectCraft's own +//! media layer (FilmCraft decoders) for MP4/MOV, the `image`/`gif` crates for stills and GIF, +//! and — when installed — ffprobe as an external oracle (skipped otherwise). +//! +//! The comp: 64×48 @ 10 fps, 1.2 s (12 frames), dark-blue background, a red solid of 32×48 +//! centred, starting at frame 5. So frame < 5 is background everywhere; from frame 5 the centre +//! is red and the corners stay background. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path}; +use effectcraft_export::{Job, Progress, export}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_raster::Image; +use effectcraft_render::{FootageSource, NoFootage}; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 64; +const H: u32 = 48; +const BG: [f32; 3] = [0.1, 0.1, 0.4]; +const FRAMES: u64 = 12; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +fn project(audio: Option<&Path>) -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2)); + comp.background = BG; + let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 32, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (32, H), None); + l.in_point = comp.frame_rate.tick_of(5); + comp.layers.push(l); + if let Some(path) = audio { + let f = effectcraft_media::probe(path).expect("probe wav"); + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None); + comp.layers.push(l); + } + let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +fn settings() -> RenderSettings { + RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() } +} + +fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report { + let s = settings(); + let path = path.to_string_lossy().to_string(); + let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path }; + let mut last = Progress::default(); + let mut calls = 0; + let r = export(&job, &mut |pr| { + assert!(pr.done >= last.done, "progress is monotonic"); + last = *pr; + calls += 1; + true + }) + .expect("export"); + assert_eq!(last.done, FRAMES); + assert_eq!(last.total, FRAMES); + assert!(calls >= 2); + assert_eq!(r.frames, FRAMES); + r +} + +fn px(img: &Image, x: u32, y: u32) -> [f32; 4] { + img.data[img.idx(x, y)] +} + +fn near(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// Check a decoded (premultiplied, opaque) frame `k`. +fn check_frame(img: &Image, k: u64, tol: f32) { + let corner = px(img, 2, 2); + assert!(near([corner[0], corner[1], corner[2]], BG, tol), "frame {k} corner {corner:?}"); + let c = px(img, img.width / 2, img.height / 2); + let want = if k >= 5 { [1.0, 0.0, 0.0] } else { BG }; + assert!(near([c[0], c[1], c[2]], want, tol), "frame {k} centre {c:?} want {want:?}"); +} + +fn decode_movie(path: &Path, frames: u64, tol: f32) -> effectcraft_project::Footage { + let f = effectcraft_media::probe(path).expect("probe our output"); + assert_eq!((f.width, f.height), (W, H)); + assert!(f.has_video); + let n = f.frame_rate.frame_at(f.duration - Tick(1)) + 1; + assert_eq!(n as u64, frames, "frame count from duration {:?} @ {:?}", f.duration, f.frame_rate); + let pool = MediaPool::new(); + for k in 0..frames { + let img = pool.frame_at(&f, f.frame_rate.tick_of(k as i64)).expect("decode"); + check_frame(&img, k, tol); + } + f +} + +fn ffprobe(path: &Path) -> Option { + let out = Command::new("ffprobe") + .args(["-v", "error", "-count_frames", "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "compact=p=0:nk=1"]) + .arg(path) + .output() + .ok()?; + if !out.status.success() { + panic!("ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + } + Some(String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Frame `k` decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg. +fn ffmpeg_frame(path: &Path, k: u64) -> Option { + let png = path.with_extension(format!("ffmpeg-{k}.png")); + let st = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) + .arg(path) + .args(["-vf", &format!("select=eq(n\\,{k})"), "-frames:v", "1", "-pix_fmt", "rgba"]) + .arg(&png) + .status() + .ok()?; + assert!(st.success(), "ffmpeg failed to decode {}", path.display()); + let img = image::open(&png).expect("ffmpeg png").to_rgba8(); + Some(Image::from_rgba8(img.width(), img.height(), img.as_raw())) +} + +fn ffmpeg_check(path: &Path, tol: f32) { + for k in [0, 4, 5, FRAMES - 1] { + match ffmpeg_frame(path, k) { + Some(img) => check_frame(&img, k, tol), + None => { + eprintln!("ffmpeg not found: skipping oracle decode"); + return; + } + } + } +} + +#[test] +fn h264_mp4_roundtrip() { + let d = out_dir("h264"); + let (p, cid) = project(None); + let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; + let path = d.join("comp.mp4"); + let r = run(&p, cid, &NoFootage, &om, &path); + assert!(r.bytes > 100 && !r.audio); + decode_movie(&path, FRAMES, 0.08); + ffmpeg_check(&path, 0.08); + if let Some(s) = ffprobe(&path) { + assert!(s.contains("h264|64|48|yuv420p"), "{s}"); + assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}"); + } +} + +#[test] +fn h264_odd_size_is_cropped_even() { + let d = out_dir("h264-odd"); + let (p, cid) = project(None); + let om = OutputModule::for_format(OutputFormat::H264); + let s = RenderSettings { resolution: 31.0 / 64.0, ..settings() }; + let comp = p.comp(cid).expect("comp"); + assert_eq!(s.output_size(comp).0 % 2, 1); + let path = d.join("odd.mp4").to_string_lossy().to_string(); + let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!((r.width % 2, r.height % 2), (0, 0)); +} + +#[test] +fn prores_mov_roundtrip() { + let d = out_dir("prores"); + let (p, cid) = project(None); + let om = OutputModule::for_format(OutputFormat::ProRes); + let path = d.join("comp.mov"); + run(&p, cid, &NoFootage, &om, &path); + decode_movie(&path, FRAMES, 0.03); + ffmpeg_check(&path, 0.03); + if let Some(s) = ffprobe(&path) { + assert!(s.starts_with("prores|64|48"), "{s}"); + assert!(s.lines().next().unwrap_or("").ends_with("|12"), "{s}"); + } +} + +#[test] +fn prores_4444_with_alpha() { + let d = out_dir("prores4444"); + let (p, cid) = project(None); + let om = OutputModule { channels: Channels::Rgba, prores_profile: ProResProfile::Hq, ..OutputModule::for_format(OutputFormat::ProRes) }; + let path = d.join("alpha.mov"); + run(&p, cid, &NoFootage, &om, &path); + let f = effectcraft_media::probe(&path).expect("probe"); + let img = MediaPool::new().frame_at(&f, f.frame_rate.tick_of(7)).expect("decode"); + assert!(px(&img, 2, 2)[3] < 0.02, "corner transparent: {:?}", px(&img, 2, 2)); + let c = px(&img, W / 2, H / 2); + assert!(c[3] > 0.98 && c[0] > 0.95 && c[1] < 0.05, "centre opaque red: {c:?}"); + if let Some(s) = ffprobe(&path) { + assert!(s.starts_with("prores|64|48|yuva444"), "{s}"); + } +} + +fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) { + for k in 0..FRAMES { + let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64); + let img = image::open(&path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8(); + assert_eq!(img.dimensions(), (W, H)); + let img = Image::from_rgba8(W, H, img.as_raw()); + if alpha { + let corner = px(&img, 2, 2); + assert!(corner[3] < 0.01, "{path}: corner {corner:?}"); + let c = px(&img, W / 2, H / 2); + assert_eq!(c[3] > 0.99, k >= 5, "{path}: centre {c:?}"); + } else { + check_frame(&img, k, tol); + } + } + assert!(!dir.join(format!("f_{:05}.{ext}", FRAMES)).exists()); +} + +#[test] +fn image_sequences_roundtrip() { + let (p, cid) = project(None); + for (fmt, ext, tol, ch) in [ + (OutputFormat::PngSequence, "png", 0.01, Channels::Rgb), + (OutputFormat::PngSequence, "png", 0.01, Channels::Rgba), + (OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb), + (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb), + (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba), + ] { + let d = out_dir(&format!("seq-{ext}-{ch:?}")); + let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) }; + let r = run(&p, cid, &NoFootage, &om, &d.join(format!("f_[#####].{ext}"))); + assert!(r.path.ends_with(&format!("f_00000.{ext}")), "{}", r.path); + check_still_dir(&d, ext, tol, ch == Channels::Rgba); + } +} + +#[test] +fn exr_sequence_is_linear() { + let (p, cid) = project(None); + let d = out_dir("seq-exr"); + let om = OutputModule::for_format(OutputFormat::ExrSequence); + run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr")); + let img = image::open(d.join("f_00000.exr")).expect("exr").to_rgba32f(); + assert_eq!(img.dimensions(), (W, H)); + let c = img.get_pixel(2, 2).0; + // sRGB 0.4 → linear ≈ 0.133 + assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}"); +} + +#[test] +fn gif_roundtrip() { + let (p, cid) = project(None); + let d = out_dir("gif"); + let om = OutputModule::for_format(OutputFormat::Gif); + let path = d.join("anim.gif"); + run(&p, cid, &NoFootage, &om, &path); + let mut opts = gif::DecodeOptions::new(); + opts.set_color_output(gif::ColorOutput::RGBA); + let mut dec = opts.read_info(std::fs::File::open(&path).expect("open")).expect("gif header"); + assert_eq!((dec.width() as u32, dec.height() as u32), (W, H)); + let mut k = 0; + let mut total_delay = 0u32; + while let Some(f) = dec.read_next_frame().expect("frame") { + assert_eq!((f.width as u32, f.height as u32), (W, H)); + total_delay += f.delay as u32; + let img = Image::from_rgba8(W, H, &f.buffer); + check_frame(&img, k, 0.06); + k += 1; + } + assert_eq!(k, FRAMES); + assert_eq!(total_delay, 120, "1.2 s in centiseconds"); + if let Some(s) = ffprobe(&path) { + assert!(s.starts_with("gif|64|48"), "{s}"); + } +} + +#[test] +fn cancel_stops_early() { + let (p, cid) = project(None); + let d = out_dir("cancel"); + let om = OutputModule::for_format(OutputFormat::PngSequence); + let s = settings(); + let path = d.join("c_[#####].png").to_string_lossy().to_string(); + let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path }; + let r = export(&job, &mut |_| false); + assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); + let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0); + assert_eq!(written, 0, "cancelled before the first batch"); +} + +/// 16-bit stereo WAV with a 440 Hz sine at amplitude 0.5 (left) and silence (right). +fn write_tone(path: &Path, secs: f64) { + let sr = 48_000u32; + let n = (secs * sr as f64) as usize; + let mut data = Vec::with_capacity(n * 4); + for i in 0..n { + let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16; + data.extend_from_slice(&s.to_le_bytes()); + data.extend_from_slice(&0i16.to_le_bytes()); + } + let mut w = Vec::new(); + w.extend_from_slice(b"RIFF"); + w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + w.extend_from_slice(b"WAVEfmt "); + w.extend_from_slice(&16u32.to_le_bytes()); + w.extend_from_slice(&1u16.to_le_bytes()); + w.extend_from_slice(&2u16.to_le_bytes()); + w.extend_from_slice(&sr.to_le_bytes()); + w.extend_from_slice(&(sr * 4).to_le_bytes()); + w.extend_from_slice(&4u16.to_le_bytes()); + w.extend_from_slice(&16u16.to_le_bytes()); + w.extend_from_slice(b"data"); + w.extend_from_slice(&(data.len() as u32).to_le_bytes()); + w.extend_from_slice(&data); + std::fs::write(path, w).expect("write wav"); +} + +fn rms(v: impl Iterator) -> f32 { + let (s, n) = v.fold((0.0f64, 0usize), |(s, n), x| (s + (x as f64) * (x as f64), n + 1)); + (s / n.max(1) as f64).sqrt() as f32 +} + +#[test] +fn movies_carry_audio() { + let d = out_dir("audio"); + let wav = d.join("tone.wav"); + write_tone(&wav, 2.0); + let (p, cid) = project(Some(&wav)); + let pool = MediaPool::new(); + for (fmt, name) in [(OutputFormat::H264, "a.mp4"), (OutputFormat::ProRes, "a.mov")] { + let om = OutputModule { audio: AudioOutput::Auto, ..OutputModule::for_format(fmt) }; + let path = d.join(name); + let r = run(&p, cid, &pool, &om, &path); + assert!(r.audio, "{name}: auto audio is on for a comp with a sound layer"); + let f = effectcraft_media::probe(&path).expect("probe"); + assert!(f.has_audio, "{name}"); + let dec = MediaPool::new(); + let s = dec.audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000); + let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied())); + // sine of amplitude 0.5 → RMS ≈ 0.354 + assert!((l - 0.3536).abs() < 0.03, "{name}: left RMS {l}"); + assert!(r < 0.01, "{name}: right RMS {r}"); + if Command::new("ffprobe").arg("-version").output().is_ok() { + let out = Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate", "-of", "compact=p=0:nk=1"]) + .arg(&path) + .output() + .expect("ffprobe"); + let s = String::from_utf8_lossy(&out.stdout); + let want = if fmt == OutputFormat::H264 { "aac|48000|2" } else { "pcm_s16le|48000|2" }; + assert!(s.trim() == want, "{name}: {s}"); + } + // Audio off. + let om = OutputModule { audio: AudioOutput::Off, ..om }; + let path = d.join(format!("silent-{name}")); + assert!(!run(&p, cid, &pool, &om, &path).audio); + assert!(!effectcraft_media::probe(&path).expect("probe").has_audio); + } +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 642fab7a3..b43271c70 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true } effectcraft-project = { workspace = true } effectcraft-media = { workspace = true } effectcraft-expr = { workspace = true } +effectcraft-export = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f542..ffc13412b 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -1,9 +1,11 @@ //! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs), -//! the media importer and the expression engine. Frontends (desktop, CLI, MCP, web) start here. +//! the media importer, the expression engine and Render Queue export through +//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here. use std::sync::Arc; -use effectcraft_engine::{Importer, Session}; +use effectcraft_engine::project::render_queue::OutputFormat; +use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session}; use effectcraft_project::Footage; struct MediaImporter; @@ -14,13 +16,40 @@ impl Importer for MediaImporter { } } -/// A new session with media, import and expressions enabled. +/// Render Queue export via `effectcraft-export`. +pub struct FileExporter; + +impl Exporter for FileExporter { + fn formats(&self) -> Vec { + effectcraft_export::available_formats() + } + fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + let j = effectcraft_export::Job { + project: job.project, + footage: job.footage, + expr: job.expr, + comp: job.item.comp, + settings: &job.item.settings, + output: &job.item.output, + path: job.path, + }; + match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { + Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }), + Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()), + Err(e) => Err(e.to_string()), + } + } +} + +/// A new session with media, import, expressions and export enabled. pub fn session() -> Session { - let mut s = Session::default(); - s.footage = Arc::new(effectcraft_media::MediaPool::new()); - s.importer = Some(Arc::new(MediaImporter)); - s.expr = Some(Arc::new(effectcraft_expr::Expressions)); - s + Session { + exporter: Some(Arc::new(FileExporter)), + footage: Arc::new(effectcraft_media::MediaPool::new()), + importer: Some(Arc::new(MediaImporter)), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + ..Default::default() + } } #[cfg(test)] @@ -37,4 +66,44 @@ mod tests { let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() }); assert!(img.data.iter().any(|p| p[3] > 0.5)); } + + #[test] + fn render_queue_exports_demo_comp() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-rq"); + let _ = std::fs::remove_dir_all(&dir); + let mut s = super::session(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let out = dir.join("[compName]_[width]x[height].[fileExtension]"); + let r = s + .execute( + "renderQueue.add", + json!({"format": "h264", "output": out.to_string_lossy(), "resolution": 0.125, "timeSpan": "custom", "start": 0.0, "end": 0.4}), + ) + .unwrap(); + let path = r["outputPath"].as_str().unwrap().to_string(); + assert!(path.ends_with("_240x134.mp4"), "{path}"); + // A second item: PNG sequence, rendered in the background. + s.execute("renderQueue.add", json!({"format": "png", "output": dir.join("seq_[###].png").to_string_lossy(), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2})).unwrap(); + let r = s.execute("renderQueue.render", json!({"wait": false})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2); + let t0 = std::time::Instant::now(); + while s.is_rendering() { + assert!(t0.elapsed().as_secs() < 300, "render timed out"); + std::thread::sleep(std::time::Duration::from_millis(20)); + s.poll_render(); + } + s.poll_render(); + let list = s.execute("renderQueue.list", json!({})).unwrap(); + for it in list["items"].as_array().unwrap() { + assert_eq!(it["statusLabel"], "Done", "{it}"); + assert!(it["render_time"].as_f64().is_some()); + assert!(std::path::Path::new(it["last_output"].as_str().unwrap()).exists(), "{it}"); + } + assert!(std::path::Path::new(&path).metadata().unwrap().len() > 500); + assert!(dir.join("seq_000.png").exists() && dir.join("seq_005.png").exists() && !dir.join("seq_006.png").exists()); + // Rendering again needs a re-queue. + assert!(s.execute("renderQueue.render", json!({})).is_err()); + s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap(); + assert_eq!(s.project.render_queue[0].status, effectcraft_engine::project::render_queue::RenderStatus::Queued); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 37787442a..fdc2c91cb 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,6 +454,9 @@ impl FootageSource for MediaPool { } } } + fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + self.audio_samples(footage, start, frames, rate) + } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 0575e7b41..800a4808f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -6,6 +6,7 @@ pub mod build; pub mod props; +pub mod render_queue; use std::collections::BTreeMap; use std::sync::Arc; @@ -586,11 +587,14 @@ pub struct Project { pub settings: ProjectSettings, pub items: BTreeMap, pub next_id: u64, + /// The Render Queue (Composition ▸ Add to Render Queue). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub render_queue: Vec, } impl Default for Project { fn default() -> Self { - Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1 } + Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1, render_queue: Vec::new() } } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs new file mode 100644 index 000000000..1eac5be1b --- /dev/null +++ b/crates/project/src/render_queue.rs @@ -0,0 +1,525 @@ +//! The Render Queue: compositions queued for export, each with Render Settings (what to render: +//! quality, resolution, time span, frame rate) and an Output Module (how to write it: format, +//! channels, codec options, output path). Saved with the project, like After Effects. +//! +//! Output paths are templates: `[compName].[fileExtension]` (the default), with the tokens +//! `[compName]`, `[projectName]`, `[fileExtension]`, `[width]`, `[height]`, `[frameRate]`, +//! `[startFrame]`, `[endFrame]`, `[outputModuleName]` and, for image sequences, a run of `#` +//! (`[#####]`) that becomes the zero-padded frame number. A relative result is resolved against the +//! project's folder (or the working directory for unsaved projects). + +use effectcraft_time::{FrameRate, Tick}; +use serde::{Deserialize, Serialize}; + +use crate::{Comp, ItemId}; + +/// The default output path template. +pub const DEFAULT_TEMPLATE: &str = "[compName].[fileExtension]"; +/// The default template for image sequences. +pub const DEFAULT_SEQUENCE_TEMPLATE: &str = "[compName]_[#####].[fileExtension]"; + +/// Render Settings ▸ Quality. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderQuality { + #[default] + Best, + Draft, +} + +/// Render Settings ▸ Time Span. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum TimeSpan { + /// The composition's work area. + #[default] + WorkArea, + /// The whole composition. + LengthOfComp, + /// An explicit span `[start, end)` in comp time. + Custom { start: Tick, end: Tick }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RenderSettings { + pub quality: RenderQuality, + /// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter). + pub resolution: f64, + pub time_span: TimeSpan, + /// `None` = use the comp's frame rate. + pub frame_rate: Option, + /// Motion blur for layers with the switch on (and the comp's Enable Motion Blur). + pub motion_blur: bool, + /// Image sequences: skip frames whose file already exists. + pub skip_existing: bool, +} + +impl Default for RenderSettings { + fn default() -> Self { + RenderSettings { + quality: RenderQuality::Best, + resolution: 1.0, + time_span: TimeSpan::WorkArea, + frame_rate: None, + motion_blur: true, + skip_existing: false, + } + } +} + +impl RenderSettings { + /// The comp-time span `[start, end)` to render. + pub fn span(&self, comp: &Comp) -> (Tick, Tick) { + let (a, b) = match self.time_span { + TimeSpan::WorkArea => comp.work_area, + TimeSpan::LengthOfComp => (Tick::ZERO, comp.duration), + TimeSpan::Custom { start, end } => (start, end), + }; + let a = a.clamp(Tick::ZERO, comp.duration); + let b = b.clamp(a, comp.duration); + (a, b) + } + pub fn rate(&self, comp: &Comp) -> FrameRate { + self.frame_rate.unwrap_or(comp.frame_rate) + } + /// First output-rate frame starting at or after `t`. + fn ceil_frame(r: FrameRate, t: Tick) -> i64 { + let f = r.frame_at(t); + if r.tick_of(f) < t { f + 1 } else { f } + } + /// Output-rate frame number of output frame 0 (the first frame starting inside the span). + pub fn first_frame(&self, comp: &Comp) -> i64 { + Self::ceil_frame(self.rate(comp), self.span(comp).0) + } + /// Number of output frames: those starting in `[start, end)` (at least 1). + pub fn frame_count(&self, comp: &Comp) -> u64 { + let (_, b) = self.span(comp); + let n = Self::ceil_frame(self.rate(comp), b) - self.first_frame(comp); + n.max(1) as u64 + } + /// Comp time of output frame `i`. + pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { + self.rate(comp).tick_of(self.first_frame(comp) + i as i64) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.resolution.clamp(0.01, 4.0); + (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + } + pub fn resolution_label(&self) -> &'static str { + match self.resolution { + r if (r - 1.0).abs() < 1e-6 => "Full", + r if (r - 0.5).abs() < 1e-6 => "Half", + r if (r - 1.0 / 3.0).abs() < 1e-3 => "Third", + r if (r - 0.25).abs() < 1e-6 => "Quarter", + _ => "Custom", + } + } + /// One-line summary shown next to "Render Settings:" (AE shows the template name). + pub fn summary(&self) -> String { + let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" }; + format!("{q} · {}", self.resolution_label()) + } +} + +/// Output Module ▸ Format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum OutputFormat { + /// H.264 video (+ AAC audio) in MP4. + #[default] + H264, + /// Apple ProRes in QuickTime (+ PCM audio). 4444 when channels include alpha. + ProRes, + PngSequence, + JpegSequence, + TiffSequence, + /// OpenEXR sequence (32-bit float, linear light). + ExrSequence, + /// Animated GIF. + Gif, +} + +impl OutputFormat { + pub const ALL: [OutputFormat; 7] = [ + OutputFormat::H264, + OutputFormat::ProRes, + OutputFormat::PngSequence, + OutputFormat::JpegSequence, + OutputFormat::TiffSequence, + OutputFormat::ExrSequence, + OutputFormat::Gif, + ]; + + pub fn label(self) -> &'static str { + match self { + OutputFormat::H264 => "H.264", + OutputFormat::ProRes => "QuickTime (ProRes)", + OutputFormat::PngSequence => "PNG Sequence", + OutputFormat::JpegSequence => "JPEG Sequence", + OutputFormat::TiffSequence => "TIFF Sequence", + OutputFormat::ExrSequence => "OpenEXR Sequence", + OutputFormat::Gif => "Animated GIF", + } + } + pub fn extension(self) -> &'static str { + match self { + OutputFormat::H264 => "mp4", + OutputFormat::ProRes => "mov", + OutputFormat::PngSequence => "png", + OutputFormat::JpegSequence => "jpg", + OutputFormat::TiffSequence => "tif", + OutputFormat::ExrSequence => "exr", + OutputFormat::Gif => "gif", + } + } + pub fn is_sequence(self) -> bool { + matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) + } + pub fn is_movie(self) -> bool { + matches!(self, OutputFormat::H264 | OutputFormat::ProRes) + } + pub fn supports_alpha(self) -> bool { + !matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence) + } + pub fn supports_audio(self) -> bool { + self.is_movie() + } + /// The written frame size for a rendered size: H.264 needs even dimensions (rounded down, + /// at least 2). + pub fn coded_size(self, w: u32, h: u32) -> (u32, u32) { + let even = |v: u32| if v < 2 { 2 } else { v & !1 }; + if self == OutputFormat::H264 { (even(w), even(h)) } else { (w, h) } + } + /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`, + /// `exr`, `gif`, or a label. + pub fn from_name(s: &str) -> Option { + let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], ""); + Some(match n.as_str() { + "h264" | "mp4" | "avc" => OutputFormat::H264, + "prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes, + "png" | "pngsequence" => OutputFormat::PngSequence, + "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, + "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence, + "exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence, + "gif" | "animatedgif" => OutputFormat::Gif, + _ => return None, + }) + } + /// Guess from a file name's extension. + pub fn from_path(path: &str) -> Option { + let ext = std::path::Path::new(path).extension()?.to_string_lossy().to_ascii_lowercase(); + OutputFormat::from_name(&ext) + } +} + +/// Output Module ▸ Channels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Channels { + /// Opaque, composited over the comp background colour. + #[default] + Rgb, + /// With straight (unmatted) alpha. + Rgba, +} + +/// Output Module ▸ Audio Output. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AudioOutput { + /// On when the comp has audible layers. + #[default] + Auto, + On, + Off, +} + +/// ProRes flavour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProResProfile { + Proxy, + Lt, + Standard, + #[default] + Hq, + P4444, + P4444Xq, +} + +impl ProResProfile { + pub const ALL: [ProResProfile; 6] = + [ProResProfile::Proxy, ProResProfile::Lt, ProResProfile::Standard, ProResProfile::Hq, ProResProfile::P4444, ProResProfile::P4444Xq]; + pub fn label(self) -> &'static str { + match self { + ProResProfile::Proxy => "Apple ProRes 422 Proxy", + ProResProfile::Lt => "Apple ProRes 422 LT", + ProResProfile::Standard => "Apple ProRes 422", + ProResProfile::Hq => "Apple ProRes 422 HQ", + ProResProfile::P4444 => "Apple ProRes 4444", + ProResProfile::P4444Xq => "Apple ProRes 4444 XQ", + } + } + pub fn is_4444(self) -> bool { + matches!(self, ProResProfile::P4444 | ProResProfile::P4444Xq) + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); + ProResProfile::ALL.into_iter().find(|p| { + let l = p.label().to_ascii_lowercase().replace(' ', ""); + l == n || l.trim_start_matches("appleprores") == n.trim_start_matches("appleprores") || format!("{p:?}").to_ascii_lowercase() == n + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OutputModule { + pub format: OutputFormat, + pub channels: Channels, + /// Output path or template (see the module docs). + pub output: String, + /// JPEG quality 1–100. + pub quality: u8, + /// H.264 target bitrate. + pub bitrate_kbps: u32, + pub prores_profile: ProResProfile, + pub audio: AudioOutput, + pub audio_sample_rate: u32, + /// GIF: loop forever. + pub gif_loop: bool, +} + +impl Default for OutputModule { + fn default() -> Self { + OutputModule { + format: OutputFormat::H264, + channels: Channels::Rgb, + output: DEFAULT_TEMPLATE.into(), + quality: 90, + bitrate_kbps: 10_000, + prores_profile: ProResProfile::Hq, + audio: AudioOutput::Auto, + audio_sample_rate: 48_000, + gif_loop: true, + } + } +} + +impl OutputModule { + pub fn for_format(format: OutputFormat) -> OutputModule { + let mut m = OutputModule { format, ..Default::default() }; + if format.is_sequence() { + m.output = DEFAULT_SEQUENCE_TEMPLATE.into(); + } + m + } + /// Change the format, keeping the output name but fixing its extension / frame-number token. + pub fn set_format(&mut self, format: OutputFormat) { + let old = self.format; + self.format = format; + if !format.supports_alpha() { + self.channels = Channels::Rgb; + } + if self.output.contains("[fileExtension]") { + if format.is_sequence() && !self.output.contains('#') { + self.output = self.output.replace(".[fileExtension]", "_[#####].[fileExtension]"); + } else if !format.is_sequence() && old.is_sequence() { + self.output = self.output.replace("_[#####]", "").replace("[#####]", ""); + } + return; + } + let p = std::path::Path::new(&self.output); + let mut stem = p.with_extension("").to_string_lossy().to_string(); + if format.is_sequence() && !stem.contains('#') { + stem.push_str("_[#####]"); + } else if !format.is_sequence() { + stem = stem.replace("_[#####]", "").replace("[#####]", ""); + } + self.output = format!("{stem}.{}", format.extension()); + } + /// One-line summary shown next to "Output Module:". + pub fn summary(&self) -> String { + let ch = match self.channels { + Channels::Rgb => "RGB", + Channels::Rgba => "RGB + Alpha", + }; + match self.format { + OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps), + OutputFormat::ProRes => format!( + "{} · {ch}", + if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() } + ), + f => format!("{} · {ch}", f.label()), + } + } +} + +/// Render Queue item status (the Status column). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub enum RenderStatus { + /// Render checkbox off. + Unqueued, + #[default] + Queued, + NeedsOutput, + Rendering, + Done, + UserStopped, + Failed(String), +} + +impl RenderStatus { + pub fn label(&self) -> &str { + match self { + RenderStatus::Unqueued => "Unqueued", + RenderStatus::Queued => "Queued", + RenderStatus::NeedsOutput => "Needs Output", + RenderStatus::Rendering => "Rendering", + RenderStatus::Done => "Done", + RenderStatus::UserStopped => "User Stopped", + RenderStatus::Failed(_) => "Failed", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RenderQueueItem { + /// Stable id (unique within the queue). + pub id: u64, + pub comp: ItemId, + /// The Render checkbox. + pub render: bool, + #[serde(default)] + pub status: RenderStatus, + #[serde(default)] + pub settings: RenderSettings, + #[serde(default)] + pub output: OutputModule, + /// Unix seconds when the last render of this item started. + #[serde(default)] + pub started: Option, + /// Seconds the last render took. + #[serde(default)] + pub render_time: Option, + /// Resolved path of the last render (first file for sequences). + #[serde(default)] + pub last_output: Option, +} + +impl RenderQueueItem { + pub fn new(id: u64, comp: ItemId) -> RenderQueueItem { + RenderQueueItem { + id, + comp, + render: true, + status: RenderStatus::Queued, + settings: RenderSettings::default(), + output: OutputModule::default(), + started: None, + render_time: None, + last_output: None, + } + } + /// Will be rendered by the next Render. + pub fn is_queued(&self) -> bool { + self.render && matches!(self.status, RenderStatus::Queued) + } +} + +/// Values for template tokens. +pub struct TemplateVars<'a> { + pub comp_name: &'a str, + pub project_name: &'a str, + pub width: u32, + pub height: u32, + pub frame_rate: f64, + pub start_frame: i64, + pub end_frame: i64, +} + +/// Expand template tokens (all but the `#` frame-number run). +pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -> String { + let fps = if (v.frame_rate - v.frame_rate.round()).abs() < 1e-6 { format!("{}", v.frame_rate.round() as i64) } else { format!("{:.2}", v.frame_rate) }; + template + .replace("[compName]", &sanitize(v.comp_name)) + .replace("[projectName]", &sanitize(v.project_name)) + .replace("[fileExtension]", format.extension()) + .replace("[width]", &v.width.to_string()) + .replace("[height]", &v.height.to_string()) + .replace("[frameRate]", &fps) + .replace("[startFrame]", &v.start_frame.to_string()) + .replace("[endFrame]", &v.end_frame.to_string()) + .replace("[outputModuleName]", format.label()) +} + +/// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame +/// number. Paths without a run get `_NNNNN` before the extension. +pub fn sequence_path(path: &str, frame: i64) -> String { + if let Some(start) = path.find('#') { + let len = path[start..].chars().take_while(|c| *c == '#').count(); + let (mut a, mut b) = (start, start + len); + if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') { + a -= 1; + b += 1; + } + return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len); + } + let p = std::path::Path::new(path); + match p.extension() { + Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()), + None => format!("{path}_{frame:05}"), + } +} + +/// Make a name safe as a file name component. +pub fn sanitize(name: &str) -> String { + name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates_and_sequences() { + let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 }; + assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4"); + assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif"); + assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png"); + assert_eq!(sequence_path("a###.tif", 7), "a007.tif"); + assert_eq!(sequence_path("a.png", 3), "a_00003.png"); + } + + #[test] + fn set_format_fixes_names() { + let mut m = OutputModule::default(); + m.set_format(OutputFormat::PngSequence); + assert_eq!(m.output, "[compName]_[#####].[fileExtension]"); + m.set_format(OutputFormat::ProRes); + assert_eq!(m.output, DEFAULT_TEMPLATE); + m.output = "/tmp/x.mov".into(); + m.set_format(OutputFormat::JpegSequence); + assert_eq!(m.output, "/tmp/x_[#####].jpg"); + assert_eq!(OutputFormat::from_name("TIFF"), Some(OutputFormat::TiffSequence)); + assert_eq!(ProResProfile::from_name("4444"), Some(ProResProfile::P4444)); + assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq)); + } + + #[test] + fn spans() { + let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0)); + c.work_area = (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(2.0)); + let mut s = RenderSettings::default(); + assert_eq!(s.frame_count(&c), 25); + assert_eq!(s.frame_time(&c, 0), Tick::from_seconds_f64(1.0)); + s.time_span = TimeSpan::LengthOfComp; + assert_eq!(s.frame_count(&c), 100); + s.frame_rate = Some(FrameRate::new(10, 1)); + assert_eq!(s.frame_count(&c), 40); + s.resolution = 0.5; + assert_eq!(s.output_size(&c), (50, 25)); + // NTSC: 1 s – 2 s holds the 30 frames that start inside it. + let c = Comp::new(100, 50, FrameRate::new(30000, 1001), Tick::from_seconds_f64(4.0)); + let s = RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(1.0), end: Tick::from_seconds_f64(2.0) }, ..Default::default() }; + assert_eq!(s.frame_count(&c), 30); + assert_eq!(s.first_frame(&c), 30); + assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0)); + } +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs new file mode 100644 index 000000000..1a9467b24 --- /dev/null +++ b/crates/render/src/audio.rs @@ -0,0 +1,203 @@ +//! Audio mixdown of a composition: every audible footage layer (and, recursively, precomp layer) +//! summed with its Audio Levels (dB, per channel), respecting in/out points, the Audio switch and +//! solo. Levels are evaluated once per call (callers mix in short blocks, e.g. one video frame). +//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are +//! silent for now. + +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; + +use crate::{EvalCtx, ExprHost, FootageSource}; + +const MAX_DEPTH: usize = 16; + +/// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz, +/// interleaved (L R L R …). +pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { + let mut out = vec![0.0f32; frames * 2]; + mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0); + out +} + +/// Whether the comp has anything audible (footage with audio, or a precomp that has). +pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool { + fn walk(project: &Project, comp: ItemId, depth: usize) -> bool { + let Some(c) = project.comp(comp) else { return false }; + depth < MAX_DEPTH + && c.layers.iter().any(|l| { + l.switches.audio + && match &l.source { + LayerSource::Footage { item } => { + matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing) + } + LayerSource::Comp { item } => walk(project, *item, depth + 1), + _ => false, + } + }) + } + walk(project, comp, 0) +} + +fn audible(project: &Project, l: &Layer) -> bool { + l.switches.audio + && match &l.source { + LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio), + LayerSource::Comp { .. } => true, + _ => false, + } +} + +fn db_to_gain(db: f64) -> f32 { + if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 } +} + +fn units_ceil(t: Tick, rate: u32) -> i64 { + let n = t.0 as i128 * rate as i128; + let d = TICKS_PER_SECOND as i128; + (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 +} + +fn sample_tick(i: i64, rate: u32) -> Tick { + Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64) +} + +#[allow(clippy::too_many_arguments)] +fn mix_into( + project: &Project, + footage: &dyn FootageSource, + expr: Option<&dyn ExprHost>, + comp_id: ItemId, + start: Tick, + rate: u32, + out: &mut [f32], + gl: f32, + gr: f32, + depth: usize, +) { + let Some(comp) = project.comp(comp_id) else { return }; + if depth >= MAX_DEPTH || rate == 0 { + return; + } + let frames = out.len() / 2; + let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l)); + let s0 = start.to_units_floor(rate as i64); + for l in &comp.layers { + if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + continue; + } + // Sample range of the block that falls inside the layer's [in, out). + let a = (units_ceil(l.in_point, rate) - s0).clamp(0, frames as i64) as usize; + let b = (units_ceil(l.out_point, rate) - s0).clamp(0, frames as i64) as usize; + if a >= b { + continue; + } + let t_a = sample_tick(s0 + a as i64, rate); + let (ll, lr) = levels(project, comp_id, comp, l, t_a, expr); + let (ll, lr) = (ll * gl, lr * gr); + if ll == 0.0 && lr == 0.0 { + continue; + } + let n = b - a; + let speed = 100.0 / l.stretch; + let src_n = ((n as f64 * speed).ceil() as usize).max(1); + let lt = l.layer_time(t_a); + let buf = match &l.source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate), + _ => continue, + }, + LayerSource::Comp { item } => { + if project.comp_contains(*item, comp_id) { + continue; + } + let mut sub = vec![0.0f32; src_n * 2]; + mix_into(project, footage, expr, *item, lt, rate, &mut sub, 1.0, 1.0, depth + 1); + sub + } + _ => continue, + }; + let same = (speed - 1.0).abs() < 1e-9; + for i in 0..n { + let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) }; + if j * 2 + 1 >= buf.len() { + break; + } + out[(a + i) * 2] += buf[j * 2] * ll; + out[(a + i) * 2 + 1] += buf[j * 2 + 1] * lr; + } + } +} + +fn levels(project: &Project, comp_id: ItemId, comp: &Comp, l: &Layer, t: Tick, expr: Option<&dyn ExprHost>) -> (f32, f32) { + let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; + let mut ctx = EvalCtx::new(project, comp_id, comp, t); + ctx.expr = expr; + let [a, b] = ctx.v2(l, g, "levels", [0.0, 0.0]); + (db_to_gain(a), db_to_gain(b)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use effectcraft_project::{Footage, FootageKind, build}; + use effectcraft_time::FrameRate; + + use super::*; + use crate::Image; + + /// A 1 kHz-ish constant "tone": every sample is 0.25 (left) / -0.25 (right). + struct Dc; + impl FootageSource for Dc { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec { + (0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect() + } + } + + #[test] + fn mixes_layers_with_levels_and_in_out() { + let mut p = Project::default(); + let f = Footage { + path: "x.wav".into(), + kind: FootageKind::Audio, + width: 0, + height: 0, + pixel_aspect: 1.0, + frame_rate: FrameRate::new(25, 1), + native_rate: None, + duration: Tick::from_seconds_f64(10.0), + has_video: false, + has_audio: true, + alpha: Default::default(), + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + }; + let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); + let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let mut l1 = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: fid }, (0, 0), None); + l1.in_point = Tick::from_seconds_f64(1.0); + let mut l2 = build::layer(&mut p, &comp, "b", LayerSource::Footage { item: fid }, (0, 0), None); + if let Some(g) = l2.props.sub_mut("audio") + && let Some(pr) = g.get_mut("levels") + { + pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]); + } + let mut comp = comp; + comp.layers = vec![l1, l2]; + let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); + assert!(comp_has_audio(&p, cid)); + let rate = 1000; + let m = mix_comp(&p, &Dc, None, cid, Tick::from_seconds_f64(0.5), 1000, rate); + // first half: only layer b (half gain left, muted right) + assert!((m[0] - 0.125).abs() < 1e-3 && m[1].abs() < 1e-6, "{} {}", m[0], m[1]); + // second half (≥ 1 s): a + b + let i = 700 * 2; + assert!((m[i] - 0.375).abs() < 1e-3 && (m[i + 1] + 0.25).abs() < 1e-3, "{} {}", m[i], m[i + 1]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd3356..dd1e9ef81 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,7 @@ //! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity. //! Adjustment layers run their effects on everything below, limited by their own bounds/masks. +pub mod audio; pub mod eval; pub mod masks; pub mod shapes; @@ -27,6 +28,11 @@ use rayon::prelude::*; pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option>; + /// `frames` stereo sample frames of `item`'s audio from source time `start` at `rate` Hz, + /// interleaved L R L R… (silence where there is none). Default: no audio. + fn audio(&self, _item: ItemId, _footage: &Footage, _start: Tick, frames: usize, _rate: u32) -> Vec { + vec![0.0; frames * 2] + } } /// No footage available (renders footage layers as transparent). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e809e8d27..4b81029af 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -400,6 +400,10 @@ impl EffectcraftApp { let ctx = ui.ctx().clone(); self.auto.begin_frame(); self.frames.set_context(&ctx); + if self.session.render_job.is_some() { + self.session.poll_render(); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } self.handle_events(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 321cee162..569d4911d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -256,6 +256,25 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V { return file_dialog(app, id); } + if id == "renderQueue.render" && params.get("wait").is_none() { + // The UI renders in the background; the panel shows progress. + let mut p = params.clone(); + if let Some(m) = p.as_object_mut() { + m.insert("wait".into(), json!(false)); + } else { + p = json!({"wait": false}); + } + app.show_panel(PanelKind::RenderQueue); + return app.session.execute(id, p).map_err(|e| e.to_string()); + } + if id == "renderQueue.add" { + let r = app.session.execute(id, params).map_err(|e| e.to_string()); + match &r { + Ok(_) => app.show_panel(PanelKind::RenderQueue), + Err(e) => app.ui.status = e.clone(), + } + return r; + } if id == "layer.rename" && params.get("name").is_none() { crate::panels::timeline::begin_rename(app, ctx); return Ok(Value::Null); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 602052700..99edc106a 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -1,7 +1,6 @@ -//! Smaller panels: Preview, Audio, History, Markers, Wiggler, Render Queue, the Home screen and -//! placeholders. +//! Smaller panels: Preview, Audio, History, Markers, Wiggler, the Home screen and placeholders. -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; use crate::dock::PanelKind; @@ -185,25 +184,6 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("wiggler.apply", b, "Apply"); } -/// Render Queue: comps queued for export with output settings. -pub fn render_queue(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); - let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 40.0)); - p.text(pos2(hdr.min.x + 14.0, hdr.center().y), Align2::LEFT_CENTER, "Render Queue", Tokens::semibold(13.0), t.text); - let b = Rect::from_min_size(pos2(hdr.max.x - 100.0, hdr.min.y + 8.0), vec2(84.0, 24.0)); - let _ = widgets::text_button(ui, b, "Render", true, &t, egui::Id::new("rq-render")); - app.auto.add("renderQueue.render", b, "Render"); - p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); - p.text( - pos2(rect.min.x + 14.0, hdr.max.y + 20.0), - Align2::LEFT_CENTER, - "Composition ▸ Add to Render Queue (⌃⌘M) to queue a render.", - Tokens::ui(12.0), - t.text_faint, - ); -} - /// Home screen: new/open, recent, and community links. pub fn start_screen(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848b..643cdf93d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod effects_presets; pub mod info; pub mod misc; pub mod project; +pub mod render_queue; pub mod text_panels; pub mod timeline; pub mod viewer; @@ -63,7 +64,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), PanelKind::Wiggler => misc::wiggler(app, ui, rect), - PanelKind::RenderQueue => misc::render_queue(app, ui, rect), + PanelKind::RenderQueue => render_queue::show(app, ui, rect), other => misc::placeholder(app, ui, rect, other), } } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs new file mode 100644 index 000000000..4a3400425 --- /dev/null +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -0,0 +1,504 @@ +//! Render Queue panel, laid out like After Effects': a Current Render strip (progress bar, +//! elapsed / estimated remaining, Stop and Render buttons), the queue list (twirl, Render +//! checkbox, label, #, Comp Name, Status, Started, Render Time) with expandable Render Settings, +//! Output Module and Output To rows, and a status footer. +//! +//! Every control dispatches an engine `renderQueue.*` command (so agents can do the same) and +//! registers an automation id: `renderQueue.render`, `renderQueue.stop`, +//! `renderQueue.item..{row,twirl,render,renderSettings,outputModule,outputTo,outputPath}`. + +use effectcraft_engine::project::render_queue::{AudioOutput, Channels, OutputFormat, ProResProfile, RenderQuality, RenderQueueItem, RenderStatus, TimeSpan}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +const TOP_H: f32 = 74.0; +const HEAD_H: f32 = 22.0; +const ROW_H: f32 = 24.0; +const FOOT_H: f32 = 24.0; + +/// `h:mm:ss` for a duration in seconds. +pub fn hms(secs: f64) -> String { + let s = secs.max(0.0).round() as u64; + format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60) +} + +/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). +pub fn utc_stamp(unix: u64) -> String { + let days = (unix / 86_400) as i64; + let rem = unix % 86_400; + // Civil-from-days (proleptic Gregorian). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60) +} + +fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { + match s { + RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c), + RenderStatus::Rendering => t.accent_hover, + RenderStatus::Failed(_) | RenderStatus::NeedsOutput => t.danger, + RenderStatus::UserStopped => t.warning, + RenderStatus::Unqueued => t.text_faint, + RenderStatus::Queued => t.text, + } +} + +/// Render Settings menu: (label, params). +fn settings_menu(it: &RenderQueueItem) -> Vec<(String, Value)> { + let s = &it.settings; + let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") }; + let mut v = vec![ + (mark(s.quality == RenderQuality::Best, "Best Settings"), json!({"quality": "best"})), + (mark(s.quality == RenderQuality::Draft, "Draft Settings"), json!({"quality": "draft"})), + ("-".into(), Value::Null), + ]; + for (l, k) in [("Full", "full"), ("Half", "half"), ("Third", "third"), ("Quarter", "quarter")] { + v.push((mark(s.resolution_label() == l, &format!("Resolution: {l}")), json!({"resolution": k}))); + } + v.push(("-".into(), Value::Null)); + v.push((mark(s.time_span == TimeSpan::WorkArea, "Time Span: Work Area Only"), json!({"timeSpan": "workArea"}))); + v.push((mark(s.time_span == TimeSpan::LengthOfComp, "Time Span: Length of Comp"), json!({"timeSpan": "comp"}))); + v.push(("-".into(), Value::Null)); + v.push((mark(s.frame_rate.is_none(), "Frame Rate: Use comp's frame rate"), json!({"frameRate": null}))); + for fps in [12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0] { + let on = s.frame_rate.is_some_and(|r| (r.as_f64() - fps).abs() < 0.01); + v.push((mark(on, &format!("Frame Rate: {fps}")), json!({"frameRate": fps}))); + } + v.push(("-".into(), Value::Null)); + v.push((mark(s.motion_blur, "Motion Blur: On for Checked Layers"), json!({"motionBlur": !s.motion_blur}))); + v.push((mark(s.skip_existing, "Skip Existing Files"), json!({"skipExisting": !s.skip_existing}))); + v +} + +/// Output Module menu: (label, params). +fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String, Value)> { + let o = &it.output; + let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") }; + let mut v = Vec::new(); + for f in OutputFormat::ALL { + if available.contains(&f) { + v.push((mark(o.format == f, &format!("Format: {}", f.label())), json!({"format": format!("{f:?}")}))); + } + } + v.push(("-".into(), Value::Null)); + v.push((mark(o.channels == Channels::Rgb, "Channels: RGB"), json!({"channels": "rgb"}))); + if o.format.supports_alpha() { + v.push((mark(o.channels == Channels::Rgba, "Channels: RGB + Alpha"), json!({"channels": "rgba"}))); + } + match o.format { + OutputFormat::ProRes => { + v.push(("-".into(), Value::Null)); + for p in ProResProfile::ALL { + v.push((mark(o.prores_profile == p, p.label()), json!({"proresProfile": format!("{p:?}")}))); + } + } + OutputFormat::H264 => { + v.push(("-".into(), Value::Null)); + for kbps in [2_000, 5_000, 10_000, 20_000, 40_000] { + v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps}))); + } + } + OutputFormat::JpegSequence => { + v.push(("-".into(), Value::Null)); + for q in [60u8, 80, 90, 100] { + v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q}))); + } + } + OutputFormat::Gif => { + v.push(("-".into(), Value::Null)); + v.push((mark(o.gif_loop, "Loop Forever"), json!({"loop": !o.gif_loop}))); + } + _ => {} + } + if o.format.supports_audio() { + v.push(("-".into(), Value::Null)); + for (a, l) in [(AudioOutput::Auto, "auto"), (AudioOutput::On, "on"), (AudioOutput::Off, "off")] { + let label = match a { + AudioOutput::Auto => "Audio Output: Auto", + AudioOutput::On => "Audio Output: On (48 kHz stereo)", + AudioOutput::Off => "Audio Output: Off", + }; + v.push((mark(o.audio == a, label), json!({"audio": l}))); + } + } + v +} + +const OUTPUT_TEMPLATES: [&str; 4] = [ + "[compName].[fileExtension]", + "[compName]_[width]x[height].[fileExtension]", + "[projectName]_[compName].[fileExtension]", + "[compName]/[compName].[fileExtension]", +]; + +fn output_to_menu(it: &RenderQueueItem) -> Vec<(String, Value)> { + let mut v: Vec<(String, Value)> = OUTPUT_TEMPLATES + .iter() + .map(|t| { + let t = if it.output.format.is_sequence() { t.replace(".[fileExtension]", "_[#####].[fileExtension]") } else { t.to_string() }; + (t.clone(), json!({"path": t})) + }) + .collect(); + v.push(("-".into(), Value::Null)); + v.push(("Choose File…".into(), json!({"choose": true}))); + v +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().with_clip_rect(rect); + let queue: Vec = app.session.project.render_queue.clone(); + let progress = app.session.render_progress(); + let rendering = app.session.is_rendering(); + let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default(); + let mut actions: Vec<(&'static str, Value)> = Vec::new(); + let state_id = egui::Id::new("rq-ui"); + let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default()); + + // ---------------------------------------------------------- Current Render strip + let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H)); + p.rect_filled(top, 0.0, t.tl_ruler_bg); + let x0 = rect.min.x + 12.0; + p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, "Current Render", Tokens::semibold(12.5), t.text); + let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); + let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); + if let Some(pr) = &progress { + let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + p.text( + pos2(top.max.x - 12.0, top.min.y + 16.0), + Align2::RIGHT_CENTER, + format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + Tokens::ui(11.5), + t.text_dim, + ); + } + let bw = 76.0; + let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); + let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); + let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); + if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { + actions.push(("renderQueue.render", json!({"wait": false}))); + } + app.auto.add("renderQueue.render", render_b, "Render"); + if rendering { + if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() { + actions.push(("renderQueue.stop", json!({}))); + } + app.auto.add("renderQueue.stop", stop_b, "Stop"); + } + // Progress bar. + let bar = + Rect::from_min_max(pos2(x0, render_b.min.y + 4.0), pos2(if rendering { stop_b.min.x - 16.0 } else { render_b.min.x - 16.0 }, render_b.max.y - 4.0)); + p.rect_filled(bar, 3.0, t.field_bg); + p.rect_stroke(bar, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let frac = progress.as_ref().filter(|p| p.total > 0).map(|p| (p.done as f32 / p.total as f32).clamp(0.0, 1.0)).unwrap_or(0.0); + if frac > 0.0 { + let fill = Rect::from_min_size(bar.min, vec2(bar.width() * frac, bar.height())); + p.rect_filled(fill, 3.0, t.accent); + } + let bar_text = match (&progress, &cur_name) { + (Some(pr), Some(n)) if rendering => format!("Rendering “{n}” {} / {} frames", pr.done, pr.total), + _ => { + let n = queue.iter().filter(|i| i.is_queued()).count(); + if available.is_empty() { + "Export is not available in this build".to_string() + } else if n == 0 { + "Composition ▸ Add to Render Queue (Ctrl/⌘ M) to queue a render".to_string() + } else { + format!("{n} item(s) queued") + } + } + }; + p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text); + app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0)); + + // ---------------------------------------------------------- column header + let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H)); + p.rect_filled(head, 0.0, t.panel_bg); + p.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); + // Columns: twirl | Render | label | # | Comp Name | Status | Started | Render Time + let cx_twirl = rect.min.x + 4.0; + let cx_render = cx_twirl + 20.0; + let cx_label = cx_render + 48.0; + let cx_num = cx_label + 20.0; + let cx_name = cx_num + 28.0; + let w_time = 92.0; + let w_started = 156.0; + let w_status = 112.0; + let cx_time = (rect.max.x - w_time).max(cx_name + 120.0); + let cx_started = cx_time - w_started; + let cx_status = cx_started - w_status; + let hy = head.center().y; + for (x, l) in [(cx_render, "Render"), (cx_num, "#"), (cx_name, "Comp Name"), (cx_status, "Status"), (cx_started, "Started"), (cx_time, "Render Time")] { + p.text(pos2(x + 2.0, hy), Align2::LEFT_CENTER, l, Tokens::ui(11.0), t.text_dim); + } + for x in [cx_render, cx_label, cx_num, cx_name, cx_status, cx_started, cx_time] { + p.line_segment([pos2(x - 3.0, head.min.y + 4.0), pos2(x - 3.0, head.max.y - 4.0)], Stroke::new(1.0, t.separator)); + } + + // ---------------------------------------------------------- items + let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H)); + let lp = p.with_clip_rect(list); + let mut y = list.min.y; + if queue.is_empty() { + lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, "The render queue is empty.", Tokens::ui(12.0), t.text_faint); + } + let editing_id = egui::Id::new("rq-edit-output"); + let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id)); + for (k, it) in queue.iter().enumerate() { + let n = k + 1; + let aid = |s: &str| format!("renderQueue.item.{n}.{s}"); + let row = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); + y += ROW_H; + let is_sel = selected == Some(it.id); + lp.rect_filled( + row, + 0.0, + if is_sel { + t.row_selected + } else if k % 2 == 0 { + t.row + } else { + t.row_alt + }, + ); + let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click()); + if row_resp.clicked() { + selected = Some(it.id); + app.ui.focused = crate::dock::PanelKind::RenderQueue; + } + let item_id = it.id; + row_resp.context_menu(|ui| { + if ui.button("Duplicate").clicked() { + actions.push(("renderQueue.duplicate", json!({"item": item_id}))); + ui.close(); + } + if ui.button("Remove").clicked() { + actions.push(("renderQueue.remove", json!({"item": item_id}))); + ui.close(); + } + if k > 0 && ui.button("Move Up").clicked() { + actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1}))); + ui.close(); + } + if n < queue.len() && ui.button("Move Down").clicked() { + actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1}))); + ui.close(); + } + }); + app.auto.add(&aid("row"), row, &format!("#{n}")); + let is_open = open.contains(&it.id); + let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0)); + if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() { + if is_open { + open.retain(|i| *i != it.id); + } else { + open.push(it.id); + } + } + app.auto.add(&aid("twirl"), tw, "Twirl"); + let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); + if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); + } + app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); + let comp_item = app.session.project.item(it.comp); + let label_col = comp_item.map(|i| Tokens::label(i.label)).unwrap_or(t.text_faint); + lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col); + lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim); + let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); + lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text); + let status = if rendering && progress.as_ref().and_then(|p| p.current) == Some(it.id) { + let pr = progress.as_ref().expect("progress"); + format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }) + } else { + it.status.label().to_string() + }; + let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H)); + lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status)); + if let RenderStatus::Failed(e) = &it.status { + ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e); + } + app.auto.add(&aid("status"), st_rect, &status); + if let Some(s) = it.started { + lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim); + } else { + lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, "-", Tokens::ui(11.5), t.text_faint); + } + let rt = if progress.as_ref().and_then(|p| p.current) == Some(it.id) && rendering { + progress.as_ref().map(|p| hms(p.item_elapsed)).unwrap_or_default() + } else { + it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into()) + }; + lp.text(pos2(cx_time + 2.0, row.center().y), Align2::LEFT_CENTER, rt, Tokens::ui(11.5), t.text_dim); + + if !is_open { + continue; + } + // Expanded: Render Settings / Output Module + Output To. + let ix = cx_name; + for sub in 0..2 { + let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); + y += ROW_H; + lp.rect_filled(r, 0.0, t.panel_bg); + let (label, summary, key, menu): (&str, String, &str, Vec<(String, Value)>) = if sub == 0 { + ("Render Settings:", it.settings.summary(), "renderSettings", settings_menu(it)) + } else { + ("Output Module:", it.output.summary(), "outputModule", output_menu(it, &available)) + }; + lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0)); + let did = egui::Id::new(("rq-dd", it.id, sub)); + if widgets::dropdown(ui, dd, &summary, &t, did).clicked() && !rendering { + widgets::open_popup(ui, did); + } + let labels: Vec = menu.iter().map(|(l, _)| l.clone()).collect(); + if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) { + let mut params = menu[i].1.clone(); + params["item"] = json!(it.id); + actions.push((if sub == 0 { "renderQueue.setRenderSettings" } else { "renderQueue.setOutputModule" }, params)); + } + app.auto.add(&aid(key), dd, &summary); + if sub == 0 { + let comp = app.session.project.comp(it.comp); + if let Some(c) = comp { + let (w, h) = it.settings.output_size(c); + let (a, b) = it.settings.span(c); + let info = format!( + "{w}×{h} · {:.3} fps · {:.2}s – {:.2}s · {} frames", + it.settings.rate(c).as_f64(), + a.seconds(), + b.seconds(), + it.settings.frame_count(c) + ); + lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint); + } + continue; + } + // Output To. + let ox = dd.max.x + 14.0; + lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, "Output To:", Tokens::ui(11.5), t.text_dim); + let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0)); + let oid = egui::Id::new(("rq-out", it.id)); + if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering { + widgets::open_popup(ui, oid); + } + app.auto.add(&aid("outputTo"), odd, "Output To"); + let omenu = output_to_menu(it); + let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect(); + if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) { + if omenu[i].1.get("choose").is_some() { + let default = app.session.resolve_output(it).unwrap_or_default(); + let picked = app + .hooks + .pick_save + .as_ref() + .and_then(|f| f(std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str())); + match picked { + Some(path) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), + None if app.hooks.pick_save.is_none() => editing = Some((it.id, it.output.output.clone())), + None => {} + } + } else { + let mut params = omenu[i].1.clone(); + params["item"] = json!(it.id); + actions.push(("renderQueue.setOutput", params)); + } + } + let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0)); + if editing.as_ref().is_some_and(|(id, _)| *id == it.id) { + let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect)); + let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5))); + if resp.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() { + actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()}))); + } + editing = None; + } else { + resp.request_focus(); + editing = Some((it.id, buf)); + } + } else { + let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()); + let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click()); + let col = if resp.hovered() { t.accent_hover } else { t.hot_text }; + lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col); + if resp.clicked() && !rendering { + editing = Some((it.id, it.output.output.clone())); + } + resp.on_hover_text(format!("Template: {}\nClick to edit", it.output.output)); + app.auto.add(&aid("outputPath"), path_rect, &shown); + } + } + } + ui.data_mut(|d| match &editing { + Some(e) => { + d.insert_temp(editing_id, e.clone()); + } + None => d.remove::<(u64, String)>(editing_id), + }); + + // Delete removes the selected item when the panel has focus. + if app.ui.focused == crate::dock::PanelKind::RenderQueue + && editing.is_none() + && !rendering + && let Some(id) = selected + && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && !ui.ctx().egui_wants_keyboard_input() + { + actions.push(("renderQueue.remove", json!({"item": id}))); + selected = None; + } + + // ---------------------------------------------------------- footer + let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max); + p.rect_filled(foot, 0.0, t.tl_ruler_bg); + p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator)); + let msg = match &progress { + Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total), + _ => { + let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count(); + format!("Message: {done} of {} done", queue.len()) + } + }; + p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim); + let started = queue.iter().filter(|i| i.started.is_some()).count(); + let total: f64 = queue.iter().filter_map(|i| i.render_time).sum(); + p.text( + pos2(foot.max.x - 12.0, foot.center().y), + Align2::RIGHT_CENTER, + format!("Renders Started: {started} Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))), + Tokens::ui(11.0), + t.text_dim, + ); + + open.retain(|id| queue.iter().any(|i| i.id == *id)); + ui.data_mut(|d| d.insert_temp(state_id, (open, selected))); + let ctx = ui.ctx().clone(); + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { + app.ui.status = e; + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn stamps() { + assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00"); + assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20"); + assert_eq!(super::hms(3725.4), "1:02:05"); + } +} diff --git a/crates/ui-egui/tests/render_queue_snapshot.rs b/crates/ui-egui/tests/render_queue_snapshot.rs new file mode 100644 index 000000000..5029a5f27 --- /dev/null +++ b/crates/ui-egui/tests/render_queue_snapshot.rs @@ -0,0 +1,43 @@ +//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU +//! adapter); run with +//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`. + +use effectcraft_engine::Session; +use effectcraft_engine::project::render_queue::RenderStatus; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +#[test] +#[ignore] +fn render_queue_panel_snapshot() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("renderQueue.add", json!({"output": "/renders/[compName].[fileExtension]", "resolution": "half", "timeSpan": "comp"})).unwrap(); + s.execute("renderQueue.add", json!({"format": "prores", "channels": "rgba", "output": "/renders/[compName]_alpha.mov"})).unwrap(); + s.execute("renderQueue.add", json!({"format": "png", "output": "/renders/frames/[compName]_[#####].png", "timeSpan": "workArea"})).unwrap(); + s.execute("renderQueue.add", json!({"format": "gif", "resolution": "quarter"})).unwrap(); + { + let p = std::sync::Arc::make_mut(&mut s.project); + p.render_queue[0].status = RenderStatus::Done; + p.render_queue[0].started = Some(1_790_000_000); + p.render_queue[0].render_time = Some(12.4); + p.render_queue[1].status = RenderStatus::Failed("disk full".into()); + p.render_queue[1].started = Some(1_790_000_013); + p.render_queue[1].render_time = Some(0.8); + p.render_queue[3].render = false; + p.render_queue[3].status = RenderStatus::Unqueued; + } + let ids: Vec = s.project.render_queue.iter().take(3).map(|i| i.id).collect(); + let mut app = Some(EffectcraftApp::new(s)); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app")); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::))); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue); + h.run_steps(4); + let img = h.render().expect("render"); + let out = std::env::var("RQ_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/render_queue.png").into()); + if let Some(d) = std::path::Path::new(&out).parent() { + std::fs::create_dir_all(d).unwrap(); + } + img.save(&out).unwrap(); +} From 8503f816ae3d2813249a50b5a5dc639388d64642 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:56:13 -0400 Subject: [PATCH 19/32] M9.3: Stylize effects (CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie, CC Plastic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stylize3.rs | 658 ++++++++++++++++++++++++++++++++- 1 file changed, 655 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 16a4399ef..df46c97ed 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -1,7 +1,659 @@ -//! Stylize effects, batch 3. +//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie +//! and CC Plastic. +//! +//! Written from the public descriptions of what these effects do (progressive block loading, +//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling, +//! a colour map sampled from the image itself); the maths is our own. -use crate::EffectSpec; +use effectcraft_color::rgb_to_hsl; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::noise::fbm; +use crate::util::{Plane, gauss_plane, layer_or_self, lerp4, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Stylize", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +// ------------------------------------------------------------------ CC Block Load + +/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`). +fn block_means(img: &Image, s: usize) -> (usize, usize, Vec) { + let (w, h) = (img.width as usize, img.height as usize); + let gw = w.div_ceil(s).max(1); + let gh = h.div_ceil(s).max(1); + let mut out = vec![[0.0f32; 4]; gw * gh]; + out.par_chunks_mut(gw).enumerate().for_each(|(by, row)| { + for (bx, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 4]; + let mut n = 0.0f32; + for y in by * s..((by + 1) * s).min(h) { + for x in bx * s..((bx + 1) * s).min(w) { + let p = img.data[y * w + x]; + for c in 0..4 { + acc[c] += p[c]; + } + n += 1.0; + } + } + if n > 0.0 { + *o = acc.map(|v| v / n); + } + } + }); + (gw, gh, out) +} + +/// Value of pixel (x, y) at block size `s` (nearest block, or bilinear between block centres). +fn block_at(g: &(usize, usize, Vec), s: usize, x: usize, y: usize, bilinear: bool) -> Px { + let (gw, gh, d) = g; + if !bilinear || s == 1 { + return d[(y / s).min(gh - 1) * gw + (x / s).min(gw - 1)]; + } + let fx = (x as f32 + 0.5) / s as f32 - 0.5; + let fy = (y as f32 + 0.5) / s as f32 - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let (tx, ty) = (fx - x0, fy - y0); + let gx = |v: f32| (v as i64).clamp(0, *gw as i64 - 1) as usize; + let gy = |v: f32| (v as i64).clamp(0, *gh as i64 - 1) as usize; + let a = d[gy(y0) * gw + gx(x0)]; + let b = d[gy(y0) * gw + gx(x0 + 1.0)]; + let c = d[gy(y0 + 1.0) * gw + gx(x0)]; + let e = d[gy(y0 + 1.0) * gw + gx(x0 + 1.0)]; + lerp4(lerp4(a, b, tx), lerp4(c, e, tx), ty) +} + +fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if c >= 1.0 { + return b; + } + let scan = ctx.params.b("scanlines"); + let smooth = ctx.params.b("smoothing"); + let cleared = ctx.params.b("startCleared"); + let bilinear = ctx.params.b("bilinear"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nlev = (w.max(h).max(2) as f64).log2().ceil() as u32; + // Levels 0..=nlev with block sizes 2^(nlev-k); level k is "loading" while p is in [k, k+1). + let p = c * (nlev + 1) as f64; + let done = p.floor() as u32; + let frac = (p - done as f64) as f32; + let size = |k: u32| 1usize << (nlev - k.min(nlev)); + let s_new = size(done); + let new = block_means(&b.img, s_new); + let old = if done > 0 { Some((size(done - 1), block_means(&b.img, size(done - 1)))) } else { None }; + let rows_new = h.div_ceil(s_new) as f32; + let soft = if smooth { 1.5f32 } else { 0.0 }; + let mut out = Image::new(w as u32, h as u32); + out.rows_mut().for_each(|(y, row)| { + // Fraction of the new level present on this row. + let t = if scan { + let front = frac * (rows_new + soft) - soft * 0.5; + let r = (y / s_new) as f32 + 0.5; + if soft > 0.0 { + smoothstep(r - soft * 0.5, r + soft * 0.5, front) + } else if r < front { + 1.0 + } else { + 0.0 + } + } else if smooth { + frac + } else { + 0.0 + }; + for (x, o) in row.iter_mut().enumerate() { + let n = if t > 0.0 { block_at(&new, s_new, x, y, bilinear) } else { [0.0; 4] }; + let prev = match &old { + Some((s, g)) => block_at(g, *s, x, y, bilinear), + None if cleared => [0.0; 4], + None => block_at(&new, s_new, x, y, bilinear), + }; + *o = if t >= 1.0 { + n + } else if t <= 0.0 { + prev + } else { + lerp4(prev, n, t) + }; + } + }); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Burn Film + +fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf { + let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32; + if burn <= 0.0 { + return b; + } + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let seed = ctx.params.f("randomSeed").max(0.0) as u32; + let s = b.scale; + let diag = (ctx.layer_size[0].hypot(ctx.layer_size[1]) * s).max(1.0); + let feat = (60.0 * s).max(1e-3); + let th = 1.0 - burn * 1.25; + let band = 0.12 * (burn * 10.0).min(1.0); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let d = ((fx - cx).hypot(fy - cy) / diag) as f32; + let n = fbm((fx / feat) as f32, (fy / feat) as f32, 0.0, seed.wrapping_add(17), 4.0); + // High near the centre and where the noise is high; burning eats from the top down. + let f = n * 0.65 + (1.0 - d.min(1.0)) * 0.35; + if f > th + band { + *px = [0.0; 4]; + continue; + } + if f > th - band * 2.0 { + let (c, a) = unpremul(*px); + // Charred rim (scorched dark) with a hot orange edge right next to the hole. + let k = smoothstep(th - band * 2.0, th, f); + let hot = smoothstep(th, th + band, f); + let char = 1.0 - k * 0.85; + let mut o = [c[0] * char, c[1] * char, c[2] * char]; + o = [o[0] + (1.0 - o[0]) * hot * 0.9, o[1] + (0.45 - o[1]) * hot * 0.9, o[2] * (1.0 - hot)]; + let na = a * (1.0 - hot * 0.6); + *px = premul(o, na); + } + } + }); + b +} + +// ------------------------------------------------------------------ bump-map surfaces (Glass, Plastic) + +const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"]; + +/// Height field (0..1) from the bump-map layer (or the layer itself), softened. +fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane { + let src = layer_or_self(ctx, b, "bumpMap", true, false); + let prop = ctx.params.e("property"); + let mut pl = Plane::from_image(&src, |p| { + let (c, a) = unpremul(p); + let v = match prop { + 0 => c[0], + 1 => c[1], + 2 => c[2], + 3 => return a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]), + _ => rgb_to_hsl(c[0], c[1], c[2]).2, + }; + v * a + }); + let soft = ctx.params.f("softness") * b.scale * 0.5; + if soft > 0.05 { + pl = gauss_plane(&pl, soft, soft); + } + pl +} + +struct Light { + intensity: f32, + color: [f32; 3], + point: bool, + pos: (f64, f64), + /// Distant light direction (towards the light). + dir: [f32; 3], + height: f32, + ambient: f32, + diffuse: f32, + specular: f32, + shininess: f32, + metal: f32, +} + +impl Light { + fn from(ctx: &EffectCtx, b: &Buf) -> Light { + let pr = &ctx.params; + let elev = (pr.f("lightHeight").clamp(-100.0, 100.0) / 100.0 * 90.0).to_radians(); + let az = pr.f("lightDirection").to_radians(); + let dir = [(az.sin() * elev.cos()) as f32, (-az.cos() * elev.cos()) as f32, elev.sin() as f32]; + let c = pr.color("lightColor"); + let rough = pr.f("roughness").clamp(0.001, 1.0) as f32; + Light { + intensity: (pr.f("lightIntensity") / 100.0) as f32, + color: [c[0], c[1], c[2]], + point: pr.e("lightType") == 1, + pos: b.to_px(pr.v2("lightPosition")), + dir, + height: (pr.f("lightHeight") / 100.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale * 0.5) as f32, + ambient: (pr.f("ambient") / 100.0) as f32, + diffuse: (pr.f("diffuse") / 100.0) as f32, + specular: (pr.f("specular") / 100.0) as f32, + shininess: 1.0 / rough, + metal: (pr.f("metal") / 100.0) as f32, + } + } + + fn to_light(&self, x: f64, y: f64) -> [f32; 3] { + if self.point { + let v = [(self.pos.0 - x) as f32, (self.pos.1 - y) as f32, self.height.max(1.0)]; + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6); + [v[0] / l, v[1] / l, v[2] / l] + } else { + self.dir + } + } + + /// Lit straight colour for surface normal `n`. Diffuse is normalised so that a flat surface + /// keeps its colour; highlights add on top. + fn shade(&self, c: [f32; 3], n: [f32; 3], l: [f32; 3]) -> [f32; 3] { + let ndl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let flat = l[2].max(0.0); + let base = self.ambient + self.diffuse * flat; + let lit = if base > 1e-4 { (self.ambient + self.diffuse * ndl) / base } else { 1.0 }; + let lit = 1.0 + (lit - 1.0) * self.intensity; + // Blinn half vector with the viewer straight above. + let hv = [l[0], l[1], l[2] + 1.0]; + let hl = (hv[0] * hv[0] + hv[1] * hv[1] + hv[2] * hv[2]).sqrt().max(1e-6); + let ndh = ((n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]) / hl).max(0.0); + let spec = self.specular * self.intensity * ndh.powf(self.shininess); + let mut o = [0.0f32; 3]; + for i in 0..3 { + let hl_col = self.color[i] * (1.0 - self.metal) + c[i] * self.color[i] * self.metal; + o[i] = c[i] * lit * (1.0 + (self.color[i] - 1.0) * self.intensity.min(1.0)) + spec * hl_col; + } + o + } +} + +/// Surface normal from a height field at pixel (x, y) with height scale `k`. +#[inline] +fn normal(h: &Plane, x: usize, y: usize, k: f32) -> [f32; 3] { + let (xi, yi) = (x as i64, y as i64); + let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) * 0.5 * k; + let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) * 0.5 * k; + let l = (gx * gx + gy * gy + 1.0).sqrt(); + [-gx / l, -gy / l, 1.0 / l] +} + +fn glass(ctx: &EffectCtx, mut b: Buf) -> Buf { + let hf = height_field(ctx, &b); + let height = ctx.params.f("height") as f32 / 100.0; + let k = height * 25.0; + let disp = ctx.params.f("displacement") * b.scale * 0.25; + let light = Light::from(ctx, &b); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let n = normal(&hf, x, y, k); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let s = if disp != 0.0 && height != 0.0 { + src.sample_bilinear_clamped(fx + n[0] as f64 * disp, fy + n[1] as f64 * disp) + } else { + src.data[y * src.width as usize + x] + }; + let a = src.data[y * src.width as usize + x][3]; + if a <= 0.0 { + *px = [0.0; 4]; + continue; + } + let (c, sa) = unpremul(s); + let c = if sa > 0.0 { c } else { unpremul(src.data[y * src.width as usize + x]).0 }; + let o = light.shade(c, n, light.to_light(fx, fy)); + *px = premul(o, a); + } + }); + b +} + +fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mut hf = height_field(ctx, &b); + let lo = (ctx.params.f("cutMin") / 100.0) as f32; + let hi = (ctx.params.f("cutMax") / 100.0) as f32; + if lo > 0.0 || hi < 1.0 { + hf = hf.map(|v| v.clamp(lo.min(hi), hi.max(lo))); + } + let k = ctx.params.f("height") as f32 / 100.0 * 25.0; + let light = Light::from(ctx, &b); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if px[3] <= 0.0 { + continue; + } + let (c, a) = unpremul(*px); + let n = normal(&hf, x, y, k); + let o = light.shade(c, n, light.to_light(x as f64 + 0.5, y as f64 + 0.5)); + *px = premul(o, a); + } + }); + b +} + +// ------------------------------------------------------------------ CC HexTile + +/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre +/// and integer cell coordinates. +fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { + let q = (3f64.sqrt() / 3.0 * x - y / 3.0) / r; + let rr = (2.0 / 3.0 * y) / r; + // Cube rounding. + let (cx, cz) = (q, rr); + let cy = -cx - cz; + 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 { + rx = -ry - rz; + } else if dy <= dz { + rz = -rx - ry; + } + let px = r * 3f64.sqrt() * (rx + rz / 2.0); + let py = r * 1.5 * rz; + ((px, py), (rx as i64, rz as i64)) +} + +fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).max(0.5); + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let rot = ctx.params.f("rotate").to_radians(); + let (sn, cs) = rot.sin_cos(); + let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0); + let mode = ctx.params.e("render"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // Into the rotated tiling frame. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let ((hx, hy), (qi, ri)) = hex_cell(u, v, r); + let (mut lu, lv) = (u - hx, v - hy); + let odd = (qi + ri).rem_euclid(2) == 1; + match mode { + 1 if odd => lu = -lu, + 2 if odd => lu = -lu, + _ => {} + } + let lv = if mode == 2 && odd { -lv } else { lv }; + // Every tile shows the hexagon around the centre point. + let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs); + let (tx, ty) = (cx + ox, cy + oy); + let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear); + *px = src.sample_bilinear_clamped(sx, sy); + } + }); + b +} + +// ------------------------------------------------------------------ CC Mr. Smoothie + +const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"]; + +fn smoothie(ctx: &EffectCtx, mut b: Buf) -> Buf { + let flow = layer_or_self(ctx, &b, "flowLayer", true, false); + let prop = ctx.params.e("property"); + let mut pl = Plane::from_image(&flow, |p| { + let (c, a) = unpremul(p); + match prop { + 0 => c[0], + 1 => c[1], + 2 => c[2], + 3 => a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]), + 5 => rgb_to_hsl(c[0], c[1], c[2]).2, + 6 => rgb_to_hsl(c[0], c[1], c[2]).0, + _ => rgb_to_hsl(c[0], c[1], c[2]).1, + } + }); + let sm = ctx.params.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + pl = gauss_plane(&pl, sm, sm); + } + let (ax, ay) = b.to_px(ctx.params.v2("sampleA")); + let (bx, by) = b.to_px(ctx.params.v2("sampleB")); + // Colour palette sampled from the image along A→B. + const N: usize = 256; + let pal: Vec = (0..N) + .map(|i| { + let t = i as f64 / (N - 1) as f64; + let p = b.img.sample_bilinear_clamped(ax + (bx - ax) * t, ay + (by - ay) * t); + let (c, _) = unpremul(p); + [c[0], c[1], c[2], 1.0] + }) + .collect(); + let phase = (ctx.params.f("phase") / 360.0) as f32; + let loops = (ctx.params.e("colorLoop") + 1) as f32; + b.img.data.par_iter_mut().zip(pl.data.par_iter()).for_each(|(px, &v)| { + if px[3] <= 0.0 { + return; + } + let t = (v * loops + phase).rem_euclid(1.0); + // Ping-pong so the palette loops without seams. + let t = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 }; + let f = t * (N - 1) as f32; + let i = (f as usize).min(N - 2); + let c = lerp4(pal[i], pal[i + 1], f - i as f32); + *px = premul([c[0], c[1], c[2]], px[3]); + }); + b +} + +// ------------------------------------------------------------------ specs + +fn light_params() -> Vec { + vec![ + p("using", "Using", Value::Enum(0), popup(&["Effect Light", "AE Lights"])), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), + p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + ] +} + +fn shading_params(ambient: f64, diffuse: f64, specular: f64, roughness: f64, metal: f64) -> Vec { + vec![ + p("ambient", "Ambient", num(ambient), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(diffuse), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(specular), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(roughness), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("metal", "Metal", num(metal), pct()), + ] +} pub fn specs() -> Vec { - Vec::new() + let mut glass_p = vec![ + p("bumpMap", "Bump Map", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)), + p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("height", "Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("displacement", "Displacement", num(100.0), slider(-500.0, 500.0, -200.0, 200.0, 1)), + ]; + glass_p.extend(light_params()); + glass_p.extend(shading_params(75.0, 32.0, 26.0, 0.05, 100.0)); + let mut plastic_p = vec![ + p("bumpLayer", "Bump Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)), + p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("height", "Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("cutMin", "Cut Min", num(0.0), pct()), + p("cutMax", "Cut Max", num(100.0), pct()), + ]; + plastic_p.extend(light_params()); + plastic_p.extend(shading_params(100.0, 70.0, 50.0, 0.05, 50.0)); + vec![ + spec( + "ec.stylize.ccblockload", + "CC Block Load", + vec![ + p("completion", "Completion", num(0.0), pct()), + p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox), + p("smoothing", "Smoothing", Value::Bool(true), ParamUi::Checkbox), + p("startCleared", "Start Cleared", Value::Bool(true), ParamUi::Checkbox), + p("bilinear", "Bilinear", Value::Bool(false), ParamUi::Checkbox), + ], + block_load, + ), + spec( + "ec.stylize.ccburnfilm", + "CC Burn Film", + vec![ + p("burn", "Burn", num(0.0), pct()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + ], + burn_film, + ), + spec("ec.stylize.ccglass", "CC Glass", glass_p, glass), + spec( + "ec.stylize.cchextile", + "CC HexTile", + vec![ + p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])), + p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("rotate", "Rotate", num(0.0), ParamUi::Angle), + p("smearing", "Smearing", num(0.0), pct()), + ], + hextile, + ), + spec( + "ec.stylize.ccmrsmoothie", + "CC Mr. Smoothie", + vec![ + p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)), + p("smoothness", "Smoothness", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("sampleA", "Sample A", pt(0.25, 0.5), ParamUi::Point), + p("sampleB", "Sample B", pt(0.75, 0.5), ParamUi::Point), + p("phase", "Phase", num(0.0), ParamUi::Angle), + p("colorLoop", "Color Loop", Value::Enum(0), popup(&["1x", "2x", "3x", "4x"])), + ], + smoothie, + ), + spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + fn bumpy(w: u32, h: u32) -> Image { + let mut img = ramp(w, h); + for y in 0..h { + for x in 0..w { + let a = if (x as i32 - 20).pow(2) + (y as i32 - 16).pow(2) < 64 { 1.0 } else { 0.5 }; + let p = img.data[(y * w + x) as usize]; + img.set(x, y, [p[0] * a, p[1] * a, p[2] * a, a]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + run_fx(id, vals, img, 0.0, EffectEnv::default()) + } + + fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) + } + + const ALL: [&str; 6] = + ["ec.stylize.ccblockload", "ec.stylize.ccburnfilm", "ec.stylize.ccglass", "ec.stylize.cchextile", "ec.stylize.ccmrsmoothie", "ec.stylize.ccplastic"]; + + #[test] + fn all_deterministic() { + for id in ALL { + let vals: &[(&str, Value)] = match id { + "ec.stylize.ccblockload" => &[("completion", num(40.0))], + "ec.stylize.ccburnfilm" => &[("burn", num(50.0))], + _ => &[], + }; + let a = run(id, vals, bumpy(40, 32)); + let b = run(id, vals, bumpy(40, 32)); + assert_eq!(a.img.data, b.img.data, "{id}"); + } + } + + #[test] + fn block_load_complete_is_identity_and_start_cleared() { + let img = ramp(40, 30); + assert_eq!(run("ec.stylize.ccblockload", &[("completion", num(100.0))], img.clone()).img.data, img.data); + let o = run("ec.stylize.ccblockload", &[("completion", num(0.0))], img.clone()); + assert!(o.img.data.iter().all(|p| p[3] == 0.0)); + // Midway: blocky (fewer distinct colours than the source). + let o = run("ec.stylize.ccblockload", &[("completion", num(50.0)), ("smoothing", Value::Bool(false))], img.clone()); + let mut cols: Vec = o.img.data.iter().map(|p| (p[0] * 1000.0) as u32).collect(); + cols.sort(); + cols.dedup(); + assert!(cols.len() < 40, "{}", cols.len()); + } + + #[test] + fn burn_film_zero_identity_full_burns() { + let img = ramp(40, 30); + assert_eq!(run("ec.stylize.ccburnfilm", &[], img.clone()).img.data, img.data); + let o = run("ec.stylize.ccburnfilm", &[("burn", num(100.0))], img); + assert!(o.img.data.iter().all(|p| p[3] == 0.0)); + } + + #[test] + fn glass_flat_without_highlight_is_identity() { + let img = bumpy(40, 32); + let o = run("ec.stylize.ccglass", &[("height", num(0.0)), ("specular", num(0.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4, "{}", max_diff(&o.img, &img)); + let o = run("ec.stylize.ccglass", &[], img.clone()); + assert!(max_diff(&o.img, &img) > 0.01); + } + + #[test] + fn plastic_shades_bumps() { + let img = bumpy(40, 32); + let o = run("ec.stylize.ccplastic", &[("height", num(0.0)), ("specular", num(0.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4); + let o = run("ec.stylize.ccplastic", &[("height", num(80.0))], img.clone()); + assert!(max_diff(&o.img, &img) > 0.01); + // Alpha is untouched. + assert!(o.img.data.iter().zip(&img.data).all(|(a, b)| (a[3] - b[3]).abs() < 1e-6)); + } + + #[test] + fn hextile_repeats_centre() { + let img = ramp(64, 64); + let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("center", pt(32.0, 32.0))], img.clone()); + assert!(max_diff(&o.img, &img) > 0.1); + // Pixels at the centre are unchanged (inside the central tile). + let c = (32 * 64 + 32) as usize; + assert!((0..4).all(|i| (o.img.data[c][i] - img.data[c][i]).abs() < 1e-4)); + // Full smearing restores the source. + let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("smearing", num(100.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4); + } + + #[test] + fn smoothie_maps_to_sampled_palette() { + let img = ramp(40, 30); + let o = run("ec.stylize.ccmrsmoothie", &[("smoothness", num(0.0)), ("sampleA", pt(10.0, 15.0)), ("sampleB", pt(30.0, 15.0))], img.clone()); + // Every output colour lies on the palette line between A and B (blue stays 0.3, green ≈ 0.5). + assert!(o.img.data.iter().all(|p| (p[2] - 0.3).abs() < 1e-3 && (p[1] - 0.5).abs() < 0.03)); + assert!(max_diff(&o.img, &img) > 0.1); + } } From 0afa24b88f8179afd08c501a1680336a2bbfd6d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:01 -0400 Subject: [PATCH 20/32] M9.3: Transition effects (CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, CC Twister, CC WarpoMatic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/transition2.rs | 543 +++++++++++++++++++++++++++++- 1 file changed, 540 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs index 5f33c4b38..2e1738b28 100644 --- a/crates/effects/src/transition2.rs +++ b/crates/effects/src/transition2.rs @@ -1,7 +1,544 @@ -//! Transition effects, batch 2. +//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, +//! CC Twister and CC WarpoMatic. +//! +//! Written from the public descriptions of these transitions (gradient-driven wipes with glass +//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that +//! reveals a back side); the maths is our own. Completion 0 leaves the layer untouched; +//! completion 100 leaves it fully transparent (or showing the revealed / back-side layer). -use crate::EffectSpec; +use effectcraft_color::rgb_to_hsl; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, fit_layer, gauss_plane, layer_or_self, lerp4, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Transition", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn completion(ctx: &EffectCtx) -> f32 { + (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32 +} + +/// The layer in layer parameter `id` fitted to `b`, if one is chosen. +fn reveal(ctx: &EffectCtx, b: &Buf, id: &str) -> Option { + ctx.layer_param(id, true).map(|o| fit_layer(ctx, b, &o, false)) +} + +const GRAD_PROPS: [&str; 7] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Saturation"]; + +fn prop_plane(img: &Image, prop: u32) -> Plane { + Plane::from_image(img, |p| { + let (c, a) = unpremul(p); + match prop { + 0 => c[0] * a, + 1 => c[1] * a, + 2 => c[2] * a, + 3 => a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]) * a, + 5 => rgb_to_hsl(c[0], c[1], c[2]).2 * a, + _ => rgb_to_hsl(c[0], c[1], c[2]).1 * a, + } + }) +} + +/// Fraction of a pixel with gradient value `g` that has transitioned, for a soft band of +/// half-width `w`: none at completion 0, all at completion 1. +#[inline] +fn wipe_t(g: f32, c: f32, w: f32) -> f32 { + let w = w.max(1e-3); + let edge = c * (1.0 + 2.0 * w) - w; + 1.0 - smoothstep(edge - w, edge + w, g) +} + +#[inline] +fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) { + let (xi, yi) = (x as i64, y as i64); + ((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5) +} + +// ------------------------------------------------------------------ CC Glass Wipe + +fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "gradientLayer", true, false); + let mut g = prop_plane(&gimg, 4); + let soft = (ctx.params.f("softness") / 100.0) as f32; + let sm = ctx.params.f("softness") * b.scale * 0.2; + if sm > 0.05 { + g = gauss_plane(&g, sm, sm); + } + let disp = (ctx.params.f("displacementAmount") * b.scale * 4.0) as f32; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + let w = soft * 0.5 + 0.01; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let t = wipe_t(g.get(x, y), c, w); + let band = 4.0 * t * (1.0 - t); + let (gx, gy) = grad(&g, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * disp * band) as f64 * 10.0, (gy * disp * band) as f64 * 10.0); + let a = if band > 1e-4 { src.sample_bilinear(fx + ox, fy + oy) } else { src.data[y * src.width as usize + x] }; + let r = match &rev { + Some(r) if band > 1e-4 => r.sample_bilinear(fx - ox, fy - oy), + Some(r) => r.data[y * r.width as usize + x], + None => [0.0; 4], + }; + *px = lerp4(a, r, t); + } + }); + b +} + +// ------------------------------------------------------------------ CC Image Wipe + +fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "layer", true, false); + let mut g = prop_plane(&gimg, ctx.params.e("property")); + let bl = ctx.params.f("blur") * b.scale * 0.5; + if bl > 0.05 { + g = gauss_plane(&g, bl, bl); + } + if ctx.params.b("inverseGradient") { + g = g.map(|v| 1.0 - v); + } + let mut w = (ctx.params.f("borderSoftness") / 100.0) as f32 * 0.5; + if ctx.params.b("autoSoftness") { + w = w.max(0.02); + } + b.img.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(px, &gv)| { + let k = 1.0 - wipe_t(gv, c, w); + *px = px.map(|v| v * k); + }); + b +} + +// ------------------------------------------------------------------ CC Jaws + +fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let width = (ctx.params.f("width") * s).max(1.0); + let amp = ctx.params.f("height") / 100.0 * width * 0.5; + let shape = ctx.params.e("shape"); + // Far enough at completion 1 that both halves (teeth included) have left the buffer. + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max); + let d = c * (vmax + 2.0 * amp + 2.0); + // Tooth profile in [-amp, amp] along the cut. + let profile = move |u: f64| -> f64 { + let ph = (u / width).rem_euclid(1.0); + match shape { + 0 => amp * (1.0 - 4.0 * (ph - 0.5).abs()), + 1 => { + // RoboJaw: square teeth with sloped flanks. + let t = (1.0 - 4.0 * (ph - 0.5).abs()) * 2.0; + amp * t.clamp(-1.0, 1.0) + } + 2 => { + if ph < 0.5 { + amp + } else { + -amp + } + } + _ => amp * (ph * std::f64::consts::TAU).sin(), + } + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the cut, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let f = profile(u); + let back = |vs: f64| { + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + src.sample_bilinear(sx, sy) + }; + *px = if v + d < f { + back(v + d) + } else if v - d >= f { + back(v - d) + } else { + [0.0; 4] + }; + } + }); + b +} + +// ------------------------------------------------------------------ CC Line Sweep + +fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let thick = (ctx.params.f("thickness") * s).max(1.0); + let slant = ctx.params.f("slant") * s; + let flip = ctx.params.b("flipDirection"); + // Projected extents of the buffer along the sweep (u) and across it (v). + let (w, h) = (b.img.width as f64, b.img.height as f64); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj = |x: f64, y: f64| (x * cs + y * sn, -x * sn + y * cs); + let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN); + for (x, y) in corners { + let (u, v) = proj(x, y); + u0 = u0.min(u); + u1 = u1.max(u); + v0 = v0.min(v); + v1 = v1.max(v); + } + let nstr = ((v1 - v0) / thick).ceil().max(1.0); + let total = (u1 - u0) + slant.abs() * nstr + 1.0; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (u, v) = proj(x as f64 + 0.5, y as f64 + 0.5); + let k = ((v - v0) / thick).floor(); + let k = if flip { nstr - 1.0 - k } else { k }; + let stagger = if slant >= 0.0 { k } else { nstr - 1.0 - k }; + let front = u0 + c * total - slant.abs() * stagger; + if u < front { + *px = [0.0; 4]; + } + } + }); + b +} + +// ------------------------------------------------------------------ CC Twister + +fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("axis").to_radians(); + let (sn, cs) = ang.sin_cos(); + let shading = ctx.params.b("shading"); + let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5; + let src = b.img.clone(); + let back = reveal(ctx, &b, "backside"); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the twist axis, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let s01 = ((u / half) * 0.5 + 0.5).clamp(0.0, 1.0); + let th = std::f64::consts::PI * (c * 2.0 - s01).clamp(0.0, 1.0); + let k = th.cos(); + if k.abs() < 1e-4 { + *px = [0.0; 4]; + continue; + } + let vs = v / k; + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + let mut o: Px = if k > 0.0 { + src.sample_bilinear(sx, sy) + } else { + // Back side: the layer itself is seen from behind (mirrored across the axis); a + // Backside layer is mapped so it reads upright when fully flipped. + match &back { + Some(bk) => bk.sample_bilinear(u * cs + vs * sn + cx, u * sn - vs * cs + cy), + None => src.sample_bilinear(sx, sy), + } + }; + if shading { + let l = (0.4 + 0.6 * k.abs()) as f32; + o = [o[0] * l, o[1] * l, o[2] * l, o[3]]; + } + *px = o; + } + }); + b +} + +// ------------------------------------------------------------------ CC WarpoMatic + +fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let rimg = layer_or_self(ctx, &b, "reactorLayer", true, false); + let mut m = prop_plane(&rimg, 4); + let sm = ctx.params.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + m = gauss_plane(&m, sm, sm); + } + m = match ctx.params.e("reactor") { + 1 => { + // Contrast differences: distance from the local mean. + let mean = gauss_plane(&m, 8.0 * b.scale, 8.0 * b.scale); + m.zip_map(&mean, |a, b| ((a - b).abs() * 4.0).min(1.0)) + } + 2 => { + // Local differences: gradient magnitude. + let mut o = Plane::new(m.w, m.h); + o.data.par_chunks_mut(m.w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (gx, gy) = grad(&m, x, y); + *v = ((gx * gx + gy * gy).sqrt() * 8.0).min(1.0); + } + }); + o + } + _ => m, + }; + let amt = (ctx.params.f("warpAmount") * b.scale) as f32 * 20.0; + let dir = ctx.params.e("warpDirection"); + let span = (ctx.params.f("blendSpan") / 100.0) as f32 * 0.5; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let q = wipe_t(m.get(x, y), c, span); + let (gx, gy) = grad(&m, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * amt * q) as f64, (gy * amt * q) as f64); + let (ix, iy) = ((gx * amt * (1.0 - q)) as f64, (gy * amt * (1.0 - q)) as f64); + let (ix, iy) = match dir { + 1 => (-ix, -iy), + 2 => (-iy, ix), + _ => (ix, iy), + }; + let out = if q < 1.0 { src.sample_bilinear(fx + ox, fy + oy) } else { [0.0; 4] }; + let inc = match &rev { + Some(r) if q > 0.0 => r.sample_bilinear(fx + ix, fy + iy), + _ => [0.0; 4], + }; + *px = lerp4(out, inc, q); + } + }); + b +} + +// ------------------------------------------------------------------ specs pub fn specs() -> Vec { - Vec::new() + let done = || p("completion", "Completion", num(0.0), pct()); + vec![ + spec( + "ec.transition.ccglasswipe", + "CC Glass Wipe", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("softness", "Softness", num(10.0), pct()), + p("displacementAmount", "Displacement Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + ], + glass_wipe, + ), + spec( + "ec.transition.ccimagewipe", + "CC Image Wipe", + vec![ + done(), + p("borderSoftness", "Border Softness", num(0.0), pct()), + p("autoSoftness", "Auto Softness", Value::Bool(true), ParamUi::Checkbox), + p("layer", "Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(4), popup(&GRAD_PROPS)), + p("blur", "Blur", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("inverseGradient", "Inverse Gradient", Value::Bool(false), ParamUi::Checkbox), + ], + image_wipe, + ), + spec( + "ec.transition.ccjaws", + "CC Jaws", + vec![ + done(), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("height", "Height", num(50.0), pct()), + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("shape", "Shape", Value::Enum(0), popup(&["Spikes", "RoboJaw", "Block", "Waves"])), + ], + jaws, + ), + spec( + "ec.transition.cclinesweep", + "CC Line Sweep", + vec![ + done(), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("thickness", "Thickness", num(20.0), slider(1.0, 500.0, 1.0, 100.0, 1)), + p("slant", "Slant", num(20.0), slider(-500.0, 500.0, -100.0, 100.0, 1)), + p("flipDirection", "Flip Direction", Value::Bool(false), ParamUi::Checkbox), + ], + line_sweep, + ), + spec( + "ec.transition.cctwister", + "CC Twister", + vec![ + done(), + p("backside", "Backside", Value::Layer(None), ParamUi::Layer), + p("shading", "Shading", Value::Bool(true), ParamUi::Checkbox), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("axis", "Axis", num(45.0), ParamUi::Angle), + ], + twister, + ), + spec( + "ec.transition.ccwarpomatic", + "CC WarpoMatic", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("reactorLayer", "Reactor Layer", Value::Layer(None), ParamUi::Layer), + p("reactor", "Reactor", Value::Enum(0), popup(&["Brightness", "Contrast Differences", "Local Differences"])), + p("smoothness", "Smoothness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("warpAmount", "Warp Amount", num(50.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("warpDirection", "Warp Direction", Value::Enum(0), popup(&["Joint", "Opposing", "Twisting"])), + p("blendSpan", "Blend Span", num(10.0), pct()), + ], + warpomatic, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + run_fx(id, vals, img, 0.0, EffectEnv::default()) + } + + const ALL: [&str; 6] = [ + "ec.transition.ccglasswipe", + "ec.transition.ccimagewipe", + "ec.transition.ccjaws", + "ec.transition.cclinesweep", + "ec.transition.cctwister", + "ec.transition.ccwarpomatic", + ]; + + fn centre(id: &str) -> Vec<(&'static str, Value)> { + match id { + "ec.transition.ccjaws" => vec![("center", pt(20.0, 16.0)), ("width", num(10.0))], + "ec.transition.cctwister" => vec![("center", pt(20.0, 16.0))], + _ => vec![], + } + } + + #[test] + fn zero_completion_is_identity() { + let img = ramp(40, 32); + for id in ALL { + let o = run(id, ¢re(id), img.clone()); + assert_eq!(o.img.data, img.data, "{id}"); + } + } + + #[test] + fn full_completion_is_transparent() { + let img = ramp(40, 32); + for id in ALL { + if id == "ec.transition.cctwister" { + continue; + } + let mut v = centre(id); + v.push(("completion", num(100.0))); + let o = run(id, &v, img.clone()); + assert!(o.img.data.iter().all(|p| p[3].abs() < 1e-5), "{id}"); + } + } + + #[test] + fn midway_deterministic_and_partial() { + let img = ramp(40, 32); + for id in ALL { + let mut v = centre(id); + v.push(("completion", num(50.0))); + let a = run(id, &v, img.clone()); + let b = run(id, &v, img.clone()); + assert_eq!(a.img.data, b.img.data, "{id}"); + let opaque = a.img.data.iter().filter(|p| p[3] > 0.99).count(); + let clear = a.img.data.iter().filter(|p| p[3] < 0.01).count(); + if id != "ec.transition.cctwister" { + assert!(opaque > 0 && clear > 0, "{id}: {opaque} {clear}"); + } + assert_ne!(a.img.data, img.data, "{id}"); + } + } + + #[test] + fn twister_full_shows_mirrored_back() { + let img = ramp(40, 32); + let o = run( + "ec.transition.cctwister", + &[("completion", num(100.0)), ("shading", Value::Bool(false)), ("axis", num(0.0)), ("center", pt(20.0, 16.0))], + img.clone(), + ); + // Axis horizontal through the centre: the back is the source flipped vertically. + for (x, y) in [(5usize, 4usize), (30, 20), (12, 27)] { + let got = o.img.data[y * 40 + x]; + let want = img.data[(31 - y) * 40 + x]; + assert!((got[1] - want[1]).abs() < 1e-3 && (got[0] - want[0]).abs() < 1e-3, "{x},{y} {got:?} {want:?}"); + } + } + + #[test] + fn line_sweep_flip_reverses_stagger() { + let img = ramp(40, 32); + let a = run("ec.transition.cclinesweep", &[("completion", num(40.0))], img.clone()); + let b = run("ec.transition.cclinesweep", &[("completion", num(40.0)), ("flipDirection", Value::Bool(true))], img.clone()); + let cleared = |o: &Buf, y: usize| (0..40).filter(|&x| o.img.data[y * 40 + x][3] < 0.01).count(); + // Unflipped: lower stripes trail; flipped: upper stripes trail. + assert!(cleared(&a, 2) > cleared(&a, 30)); + assert!(cleared(&b, 2) < cleared(&b, 30)); + } + + #[test] + fn image_wipe_follows_gradient() { + let img = ramp(40, 32); + let o = run("ec.transition.ccimagewipe", &[("completion", num(50.0)), ("property", Value::Enum(0))], img.clone()); + // Red ramps left→right: dark (left) side goes first. + assert!(o.img.data[16 * 40 + 2][3] < 0.01); + assert!(o.img.data[16 * 40 + 37][3] > 0.99); + } } From d98775a931ede795b0e67cffad4e8d40393c2b78 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 21/32] M9.3: Channel effects (Blend, Calculations, CC Composite, Compound Arithmetic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/channel2.rs | 447 ++++++++++++++++++++++++++++++++- 1 file changed, 444 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 70516adc5..259849846 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -1,7 +1,448 @@ -//! Channel effects, batch 2. +//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound +//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect +//! host; with no layer chosen the effect uses the layer itself, as After Effects does. -use crate::EffectSpec; +use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{layer_or_self, lerp4, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Channel", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn layer_param(id: &'static str, name: &'static str) -> crate::ParamSpec { + p(id, name, Value::Layer(None), ParamUi::Layer) +} + +const SIZE_DIFF: [&str; 2] = ["Center", "Stretch to Fit"]; + +// ---------------------------------------------------------------- Blend + +fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mode = ctx.params.e("mode"); + let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return b; + } + let other = layer_or_self(ctx, &b, "blendWithLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1); + b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| { + let src = *px; + let blended = match mode { + // Crossfade: the other layer replaces this one. + 0 => o, + // Color Only / Tint Only: hue (and saturation) from the other layer, lightness kept. + 1 | 2 => { + let (c, a) = unpremul(src); + let (oc, oa) = unpremul(o); + if a <= 0.0 || oa <= 0.0 { + src + } else { + let (_, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (oh, os, _) = rgb_to_hsl(oc[0], oc[1], oc[2]); + let (h2, s2) = if mode == 1 { + (oh, os) + } else if s > 1e-4 { + (oh, s) + } else { + (0.0, 0.0) + }; + let (r, g, bb) = hsl_to_rgb(h2, s2, l); + let t = oa; + lerp4(src, premul([r, g, bb], a), t) + } + } + 3 => [src[0].min(o[0]), src[1].min(o[1]), src[2].min(o[2]), src[3]], + _ => [src[0].max(o[0]), src[1].max(o[1]), src[2].max(o[2]), src[3].max(o[3])], + }; + *px = lerp4(blended, src, orig); + }); + b +} + +// ---------------------------------------------------------------- Calculations + +/// Blending modes offered by Calculations / CC Composite (menu order). +const CALC_MODES: [BlendMode; 22] = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::Add, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::LinearLight, + BlendMode::VividLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Luminosity, +]; + +fn calc_mode_names() -> Vec<&'static str> { + CALC_MODES.iter().map(|m| m.label()).collect() +} + +const CHANNELS: [&str; 6] = ["RGBA", "Gray", "Red", "Green", "Blue", "Alpha"]; + +/// Extract channel `ch` (index into [`CHANNELS`]) of a premultiplied pixel as a premultiplied +/// pixel: RGBA unchanged, single channels as opaque grey (Alpha as grey too). +#[inline] +fn extract(px: [f32; 4], ch: u32, invert: bool) -> [f32; 4] { + let (c, a) = unpremul(px); + let g = match ch { + 0 => { + if invert { + return premul([1.0 - c[0], 1.0 - c[1], 1.0 - c[2]], a); + } + return px; + } + 1 => luminance(c[0], c[1], c[2]) * a, + 2 => c[0] * a, + 3 => c[1] * a, + 4 => c[2] * a, + _ => a, + }; + let g = if invert { 1.0 - g } else { g }; + [g, g, g, 1.0] +} + +fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf { + let in_ch = ctx.params.e("inputChannel"); + let in_inv = ctx.params.b("invertInput"); + let second = ctx.params.get("secondLayer").and_then(Value::as_layer).is_some(); + let sec_ch = ctx.params.e("secondLayerChannel"); + let sec_op = (ctx.params.f("secondLayerOpacity") as f32 / 100.0).clamp(0.0, 1.0); + let sec_inv = ctx.params.b("invertSecondLayer"); + let mode = CALC_MODES[(ctx.params.e("blendingMode") as usize).min(CALC_MODES.len() - 1)]; + let keep_alpha = ctx.params.b("preserveTransparency"); + let other = if second { Some(layer_or_self(ctx, &b, "secondLayer", true, ctx.params.b("stretchSecondLayerToFit"))) } else { None }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let orig_a = px[3]; + let mut o = extract(*px, in_ch, in_inv); + if let Some(other) = &other { + let s = extract(other.data[i], sec_ch, sec_inv).map(|v| v * sec_op); + o = blend_pixel(mode, o, s, 0.5); + } + if keep_alpha && o[3] > 1e-6 { + let (c, _) = unpremul(o); + o = premul(c, orig_a); + } + o[3] = o[3].clamp(0.0, 1.0); + *px = o; + }); + b +} + +// ---------------------------------------------------------------- CC Composite + +const CC_COMPOSITE_MODES: [&str; 16] = [ + "In Front", + "In Back", + "Add", + "Multiply", + "Screen", + "Overlay", + "Soft Light", + "Hard Light", + "Darken", + "Lighten", + "Difference", + "Hue", + "Saturation", + "Color", + "Luminosity", + "Stencil Alpha", +]; + +/// Composites the original over/under/with the effect result. The original is the image as it +/// enters this effect (the host does not expose the pre-effect source). +fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { + let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0); + let m = ctx.params.e("compositeOriginal"); + let rgb_only = ctx.params.b("rgbOnly"); + let mode = match m { + 2 => BlendMode::Add, + 3 => BlendMode::Multiply, + 4 => BlendMode::Screen, + 5 => BlendMode::Overlay, + 6 => BlendMode::SoftLight, + 7 => BlendMode::HardLight, + 8 => BlendMode::Darken, + 9 => BlendMode::Lighten, + 10 => BlendMode::Difference, + 11 => BlendMode::Hue, + 12 => BlendMode::Saturation, + 13 => BlendMode::Color, + 14 => BlendMode::Luminosity, + 15 => BlendMode::StencilAlpha, + _ => BlendMode::Normal, + }; + b.img.data.par_iter_mut().for_each(|px| { + let result = *px; + let orig = result.map(|v| v * op); + let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) }; + o[3] = o[3].clamp(0.0, 1.0); + if rgb_only { + let (c, _) = unpremul(o); + o = premul(c, result[3]); + } + *px = o; + }); + b +} + +// ---------------------------------------------------------------- Compound Arithmetic + +const OPERATORS: [&str; 15] = + ["Copy", "Add", "Subtract", "Multiply", "Difference", "And", "Or", "Xor", "Lighten", "Darken", "Minimum", "Maximum", "Screen", "Overlay", "Hard Light"]; + +/// One channel operation on straight values in 0..1 (may overflow). +#[inline] +fn arith(op: u32, a: f32, b: f32) -> f32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32; + match op { + 0 => b, + 1 => a + b, + 2 => a - b, + 3 => a * b, + 4 => (a - b).abs(), + 5 => (q(a) & q(b)) as f32 / 255.0, + 6 => (q(a) | q(b)) as f32 / 255.0, + 7 => (q(a) ^ q(b)) as f32 / 255.0, + 8 | 11 => a.max(b), + 9 | 10 => a.min(b), + 12 => a + b - a * b, + 13 => { + if a < 0.5 { + 2.0 * a * b + } else { + 1.0 - 2.0 * (1.0 - a) * (1.0 - b) + } + } + _ => { + if b < 0.5 { + 2.0 * a * b + } else { + 1.0 - 2.0 * (1.0 - a) * (1.0 - b) + } + } + } +} + +/// Overflow handling: 0 Clip, 1 Wrap, 2 Scale (map the operator's full range to 0..1). +#[inline] +fn overflow(op: u32, v: f32, how: u32) -> f32 { + match how { + 0 => v.clamp(0.0, 1.0), + 1 => v.rem_euclid(1.0001).min(1.0), + _ => match op { + 1 => v * 0.5, + 2 => (v + 1.0) * 0.5, + _ => v.clamp(0.0, 1.0), + }, + } +} + +fn compound_arithmetic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let op = ctx.params.e("operator"); + let chans = ctx.params.e("operateOnChannels"); + let how = ctx.params.e("overflowBehavior"); + let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return b; + } + let other = layer_or_self(ctx, &b, "secondSource", true, ctx.params.b("stretchSecondSourceToFit")); + b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| { + let src = *px; + let (c, a) = unpremul(src); + let (oc, oa) = unpremul(o); + let mut rc = c; + let mut ra = a; + if chans != 2 { + for k in 0..3 { + rc[k] = overflow(op, arith(op, c[k], oc[k]), how); + } + } + if chans != 0 { + ra = overflow(op, arith(op, a, oa), how); + } + let res = premul(rc, ra); + *px = lerp4(res, src, orig); + }); + b +} pub fn specs() -> Vec { - Vec::new() + let calc_modes = calc_mode_names(); + vec![ + spec( + "ec.channel.blend", + "Blend", + vec![ + layer_param("blendWithLayer", "Blend With Layer"), + p("mode", "Mode", Value::Enum(0), popup(&["Crossfade", "Color Only", "Tint Only", "Darken Only", "Lighten Only"])), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&SIZE_DIFF)), + ], + blend, + ), + spec( + "ec.channel.calculations", + "Calculations", + vec![ + p("inputChannel", "Input Channel", Value::Enum(0), popup(&CHANNELS)), + p("invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox), + layer_param("secondLayer", "Second Layer"), + p("secondLayerChannel", "Second Layer Channel", Value::Enum(0), popup(&CHANNELS)), + p("secondLayerOpacity", "Second Layer Opacity", num(100.0), pct()), + p("invertSecondLayer", "Invert Second Layer", Value::Bool(false), ParamUi::Checkbox), + p("stretchSecondLayerToFit", "Stretch Second Layer to Fit", Value::Bool(true), ParamUi::Checkbox), + p("blendingMode", "Blending Mode", Value::Enum(0), ParamUi::Popup { options: calc_modes.iter().map(|s| s.to_string()).collect() }), + p("preserveTransparency", "Preserve Transparency", Value::Bool(false), ParamUi::Checkbox), + ], + calculations, + ), + spec( + "ec.channel.cccomposite", + "CC Composite", + vec![ + p("opacity", "Opacity", num(100.0), pct()), + p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)), + p("rgbOnly", "RGB Only", Value::Bool(false), ParamUi::Checkbox), + ], + cc_composite, + ), + spec( + "ec.channel.compoundarithmetic", + "Compound Arithmetic", + vec![ + layer_param("secondSource", "Second Source Layer"), + p("operator", "Operator", Value::Enum(0), popup(&OPERATORS)), + p("operateOnChannels", "Operate On Channels", Value::Enum(0), popup(&["RGB", "ARGB", "Alpha"])), + p("overflowBehavior", "Overflow Behavior", Value::Enum(0), popup(&["Clip", "Wrap", "Scale"])), + p("stretchSecondSourceToFit", "Stretch Second Source to Fit", Value::Bool(true), ParamUi::Checkbox), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + compound_arithmetic, + ), + ] +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + use effectcraft_raster::Image; + + /// A host that knows one other layer (id 7). + pub(crate) struct FakeHost(pub Image); + impl EffectHost for FakeHost { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + pub(crate) fn sample() -> Image { + let mut img = Image::new(8, 6); + for y in 0..6 { + for x in 0..8 { + let a = if x < 6 { 1.0 } else { 0.5 }; + img.set(x, y, [x as f32 / 8.0 * a, y as f32 / 6.0 * a, 0.4 * a, a]); + } + } + img + } + + fn close(a: &Image, b: &Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) + } + + fn with_host(other: Image, f: impl FnOnce(EffectEnv) -> R) -> R { + let host = FakeHost(other); + f(EffectEnv { host: Some(&host), ..Default::default() }) + } + + #[test] + fn blend_self_crossfade_is_identity_and_layer_crossfades() { + let img = sample(); + let out = run_fx("ec.channel.blend", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let red = Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]); + let out = with_host(red, |env| { + run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env) + }); + let p = out.img.get(0, 0); + assert!((p[0] - 0.5).abs() < 1e-5 && (p[3] - 1.0).abs() < 1e-5); + let again = with_host(Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]), |env| { + run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env) + }); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn calculations_defaults_identity_and_gray() { + let img = sample(); + let out = run_fx("ec.channel.calculations", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let g = run_fx("ec.channel.calculations", &[("inputChannel", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default()); + let p = g.img.get(4, 2); + assert!((p[0] - 0.5).abs() < 1e-5 && p[0] == p[1] && p[1] == p[2]); + let m = with_host(Image::filled(8, 6, [0.5, 0.5, 0.5, 1.0]), |env| { + run_fx("ec.channel.calculations", &[("secondLayer", Value::Layer(Some(7))), ("blendingMode", Value::Enum(2))], img.clone(), 0.0, env) + }); + let q = m.img.get(4, 0); + assert!((q[0] - 0.25).abs() < 1e-4, "{q:?}"); + } + + #[test] + fn cc_composite_in_front_is_identity_on_opaque() { + let img = Image::filled(4, 4, [0.3, 0.6, 0.2, 1.0]); + let out = run_fx("ec.channel.cccomposite", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let add = run_fx("ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default()); + assert!((add.img.get(0, 0)[0] - 0.6).abs() < 1e-5); + } + + #[test] + fn compound_arithmetic_copy_self_identity_and_add() { + let img = sample(); + let out = run_fx("ec.channel.compoundarithmetic", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-5)); + let other = Image::filled(8, 6, [0.25, 0.25, 0.25, 1.0]); + let add = with_host(other, |env| { + run_fx("ec.channel.compoundarithmetic", &[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1))], img.clone(), 0.0, env) + }); + let p = add.img.get(4, 3); + assert!((p[0] - (0.5 + 0.25)).abs() < 1e-5 && (p[1] - 0.75).abs() < 1e-5, "{p:?}"); + let sc = with_host(Image::filled(8, 6, [1.0, 1.0, 1.0, 1.0]), |env| { + run_fx( + "ec.channel.compoundarithmetic", + &[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1)), ("overflowBehavior", Value::Enum(2))], + img.clone(), + 0.0, + env, + ) + }); + assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5); + } } From 5ac5560df9903f1227844f5ee1faec5042d2ec09 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 22/32] M9.3: Keying effects (Difference Matte, Inner/Outer Key, Unmult, CC Simple Wire Removal) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/keying2.rs | 418 +++++++++++++++++++++++++++++++++- 1 file changed, 415 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 5b514cb9c..caad75ec7 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -1,7 +1,419 @@ -//! Keying effects, batch 2. +//! Keying effects, batch 2: Difference Matte (against another layer), Inner/Outer Key (mattes +//! from masks with colour-based edge estimation and decontamination), Unmult and CC Simple +//! Wire Removal. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::gaussian_blur; +use rayon::prelude::*; + +use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, lerp4, morph_frac, point_in_poly, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Keying", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- Difference Matte + +fn difference_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let view = ctx.params.e("view"); + if view == 1 { + return b; + } + let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32; + let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32; + let blur = ctx.params.f("blurBeforeDifference") * b.scale; + let other = layer_or_self(ctx, &b, "differenceLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1); + let (src, other) = if blur > 0.05 { + (gaussian_blur(&b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(&other, blur * 0.5, blur * 0.5, true)) + } else { + (b.img.clone(), other) + }; + let matte_only = view == 2; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(src.data[i]); + let (oc, oa) = unpremul(other.data[i]); + let d = (c[0] - oc[0]).abs().max((c[1] - oc[1]).abs()).max((c[2] - oc[2]).abs()).max((a - oa).abs()); + let m = if d <= tol { + 0.0 + } else if soft <= 1e-6 { + 1.0 + } else { + ((d - tol) / soft).clamp(0.0, 1.0) + }; + if matte_only { + *px = [m * px[3], m * px[3], m * px[3], px[3]]; + } else { + *px = px.map(|v| v * m); + } + }); + b +} + +// ---------------------------------------------------------------- Inner/Outer Key + +fn mask_options() -> ParamUi { + popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"]) +} + +/// Coverage plane (1 inside) of the selected closed masks, in buffer pixels. +fn mask_plane(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { + let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); + if shapes.is_empty() { + return None; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut pl = Plane::new(w, h); + let inv = 1.0 / b.scale.max(1e-9); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + *v = if shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)) { 1.0 } else { 0.0 }; + } + }); + Some(pl) +} + +/// Signed distance (pixels, positive inside) to the selected mask outlines. +fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { + let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); + if shapes.is_empty() { + return None; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut pl = Plane::new(w, h); + let inv = 1.0 / b.scale.max(1e-9); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + let inside = shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)); + let d = shapes.iter().map(|s| dist_to_poly(&s.points, true, lx, ly)).fold(f64::INFINITY, f64::min) * b.scale; + *v = if inside { d as f32 } else { -d as f32 }; + } + }); + Some(pl) +} + +fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")]; + let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")]; + let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32; + let thin = ctx.params.f("edgeThin") * b.scale; + let feather = ctx.params.f("edgeFeather") * b.scale; + let thr = (ctx.params.f("edgeThreshold") / 100.0) as f32; + let invert = ctx.params.b("invertExtraction"); + let orig = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (w, h) = (b.img.width as usize, b.img.height as usize); + // Trimap: 1 = known foreground, 0 = known background, 0.5 = unknown. + let fg = mask_plane(ctx, &b, &fg_idx); + let bg = mask_plane(ctx, &b, &bg_idx); + let tri: Plane = match (fg, bg) { + (None, _) => return b, + (Some(f), Some(bk)) => f.zip_map(&bk, |f, bk| { + if f > 0.5 { + 1.0 + } else if bk > 0.5 { + 0.5 + } else { + 0.0 + } + }), + (Some(f), None) => { + if radius <= 0.0 { + f + } else { + let sdf = mask_sdf(ctx, &b, &fg_idx).unwrap_or(f); + sdf.map(|d| { + if d > radius { + 1.0 + } else if d < -radius { + 0.0 + } else { + 0.5 + } + }) + } + } + }; + let unknown = tri.data.par_iter().any(|&t| t > 0.25 && t < 0.75); + let mut alpha = tri.clone(); + let mut colours: Option> = None; + if unknown { + // Local foreground / background colour estimates: normalised blurs over known regions. + let sigma = (radius.max(4.0) as f64).max(w.min(h) as f64 * 0.02); + let known_f = tri.map(|t| if t > 0.75 { 1.0 } else { 0.0 }); + let known_b = tri.map(|t| if t < 0.25 { 1.0 } else { 0.0 }); + let straight: Vec<[f32; 3]> = b.img.data.par_iter().map(|&px| unpremul(px).0).collect(); + let est = |known: &Plane| -> Vec<[f32; 3]> { + let wsum = gauss_plane(known, sigma, sigma); + let chans: Vec = (0..3) + .map(|k| { + let pl = Plane { w, h, data: straight.par_iter().zip(known.data.par_iter()).map(|(c, &m)| c[k] * m).collect() }; + gauss_plane(&pl, sigma, sigma) + }) + .collect(); + (0..w * h).into_par_iter().map(|i| [0, 1, 2].map(|k| chans[k].data[i] / wsum.data[i].max(1e-6))).collect() + }; + let fc = est(&known_f); + let bc = est(&known_b); + alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| { + if *a > 0.25 && *a < 0.75 { + let c = straight[i]; + let d = [fc[i][0] - bc[i][0], fc[i][1] - bc[i][1], fc[i][2] - bc[i][2]]; + let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + *a = if dd < 1e-6 { 0.5 } else { (((c[0] - bc[i][0]) * d[0] + (c[1] - bc[i][1]) * d[1] + (c[2] - bc[i][2]) * d[2]) / dd).clamp(0.0, 1.0) }; + } + }); + // Decontaminate edge colours: remove the background contribution. + colours = Some( + (0..w * h) + .into_par_iter() + .map(|i| { + let a = alpha.data[i]; + let c = straight[i]; + if tri.data[i] > 0.25 && tri.data[i] < 0.75 && a > 0.02 { + [0, 1, 2].map(|k| ((c[k] - (1.0 - a) * bc[i][k]) / a).clamp(0.0, 1.0)) + } else { + c + } + }) + .collect(), + ); + } + if thin.abs() > 0.01 { + alpha = morph_frac(&alpha, thin.abs(), thin < 0.0); + } + if feather > 0.05 { + alpha = gauss_plane(&alpha, feather * 0.5, feather * 0.5); + } + let src = b.img.clone(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut m = alpha.data[i]; + if thr > 0.0 { + m = if m < thr { 0.0 } else { m }; + } + if invert { + m = 1.0 - m; + } + let (c0, a) = unpremul(src.data[i]); + let c = colours.as_ref().map(|v| v[i]).unwrap_or(c0); + let keyed = premul(c, a * m); + *px = lerp4(keyed, src.data[i], orig); + }); + b +} + +// ---------------------------------------------------------------- Unmult + +/// Turns black into transparency: alpha becomes the brightest straight channel and colour is +/// divided by it (premultiplied colour is unchanged). +fn unmult(_: &EffectCtx, mut b: Buf) -> Buf { + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let m = c[0].max(c[1]).max(c[2]).clamp(0.0, 1.0); + if m <= 1e-6 { + *px = [0.0; 4]; + return; + } + *px = premul([c[0] / m, c[1] / m, c[2] / m], a * m); + }); + b +} + +// ---------------------------------------------------------------- CC Simple Wire Removal + +fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pa = ctx.params.v2("pointA"); + let pb = ctx.params.v2("pointB"); + let style = ctx.params.e("removalStyle"); + let half = (ctx.params.f("thickness") * b.scale * 0.5).max(0.0); + if half <= 0.0 { + return b; + } + let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; + let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; + let (ax, ay) = b.to_px(pa); + let (bx, by) = b.to_px(pb); + let (dx, dy) = (bx - ax, by - ay); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return b; + } + let (tx, ty) = (dx / len, dy / len); + let (nx, ny) = (-ty, tx); + let src = b.img.clone(); + let soft_w = half * slope as f64; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); + let d = qx * nx + qy * ny; + let ad = d.abs(); + if ad > half + soft_w { + continue; + } + // Positions just outside the wire on both sides. + let base = (x as f64 + 0.5 - d * nx, y as f64 + 0.5 - d * ny); + let edge = half + 1.0; + let pos = |s: f64| src.sample_bilinear_clamped(base.0 + nx * s, base.1 + ny * s); + let repl = match style { + 0 => { + let t = ((d + edge) / (2.0 * edge)) as f32; + lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) + } + // Displace: shift the image across the wire from the nearer side. + 2 | 1 => { + let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; + let sh = pos(s); + if mirror > 0.0 { lerp4(sh, pos(-s), mirror * 0.5) } else { sh } + } + _ => { + // Displace Horizontal: sample left/right of the wire along x. + let sx = if nx.abs() > 1e-6 { (half - ad + 1.0) / nx.abs() } else { 0.0 }; + let dir = if d * nx >= 0.0 { 1.0 } else { -1.0 }; + let sh = src.sample_bilinear_clamped(x as f64 + 0.5 + dir * sx, y as f64 + 0.5); + let mi = src.sample_bilinear_clamped(x as f64 + 0.5 - dir * sx, y as f64 + 0.5); + if mirror > 0.0 { lerp4(sh, mi, mirror * 0.5) } else { sh } + } + }; + let k = if ad <= half { 1.0 } else { 1.0 - smoothstep(half as f32, (half + soft_w) as f32, ad as f32) }; + *px = lerp4(*px, repl, k); + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.key.differencematte", + "Difference Matte", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Source Only", "Matte Only"])), + p("differenceLayer", "Difference Layer", Value::Layer(None), ParamUi::Layer), + p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("matchingTolerance", "Matching Tolerance", num(15.0), pct()), + p("matchingSoftness", "Matching Softness", num(0.0), pct()), + p("blurBeforeDifference", "Blur Before Difference", num(0.0), slider(0.0, 1000.0, 0.0, 20.0, 1)), + ], + difference_matte, + ), + spec( + "ec.key.innerouter", + "Inner/Outer Key", + vec![ + p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()), + p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()), + p("background", "Background (Outside)", Value::Enum(0), mask_options()), + p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()), + p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)), + p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("edgeThreshold", "Edge Threshold", num(0.0), pct()), + p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox), + p("blendWithOriginal", "Blend with Original", num(0.0), pct()), + ], + inner_outer_key, + ), + spec("ec.key.unmult", "Unmult", vec![], unmult), + spec( + "ec.key.ccsimplewireremoval", + "CC Simple Wire Removal", + vec![ + p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point), + p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point), + p("removalStyle", "Removal Style", Value::Enum(2), popup(&["Fade", "Frame Offset", "Displace", "Displace Horizontal"])), + p("thickness", "Thickness", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 1)), + p("slope", "Slope", num(0.0), pct()), + p("mirrorBlend", "Mirror Blend", num(0.0), pct()), + p("frameOffset", "Frame Offset", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 0)), + ], + wire_removal, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel2::tests::{FakeHost, sample}; + use crate::{EffectEnv, MaskShape, run_fx}; + use effectcraft_raster::Image; + + #[test] + fn difference_matte_self_is_transparent_and_other_keeps() { + let img = sample(); + let out = run_fx("ec.key.differencematte", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(out.img.data.iter().all(|p| p[3] == 0.0)); + let host = FakeHost(Image::filled(8, 6, [0.0, 0.0, 0.4, 1.0])); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); + assert_eq!(out.img.get(5, 5), img.get(5, 5)); + assert_eq!(out.img.get(0, 0)[3], 0.0, "matching pixel keyed out"); + let again = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn inner_outer_key_inside_mask() { + let img = Image::filled(20, 20, [0.8, 0.2, 0.2, 1.0]); + let masks = vec![MaskShape { name: "Mask 1".into(), points: vec![[5.0, 5.0], [15.0, 5.0], [15.0, 15.0], [5.0, 15.0]], closed: true, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let out = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1))], img.clone(), 0.0, env); + assert!((out.img.get(10, 10)[3] - 1.0).abs() < 1e-6); + assert_eq!(out.img.get(1, 1)[3], 0.0); + // Two masks: unknown band estimated from colour. + let mut img2 = Image::filled(20, 20, [0.0, 0.0, 1.0, 1.0]); + for y in 0..20 { + for x in 7..13 { + img2.set(x, y, [1.0, 0.0, 0.0, 1.0]); + } + } + let masks2 = vec![ + MaskShape { name: "in".into(), points: vec![[8.0, 0.0], [12.0, 0.0], [12.0, 20.0], [8.0, 20.0]], closed: true, inverted: false }, + MaskShape { name: "out".into(), points: vec![[4.0, 0.0], [16.0, 0.0], [16.0, 20.0], [4.0, 20.0]], closed: true, inverted: false }, + ]; + let env2 = EffectEnv { masks: &masks2, ..Default::default() }; + let o2 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2.clone(), 0.0, env2); + assert!(o2.img.get(7, 10)[3] > 0.8, "{:?}", o2.img.get(7, 10)); + assert!(o2.img.get(5, 10)[3] < 0.2, "{:?}", o2.img.get(5, 10)); + assert_eq!(o2.img.get(1, 10)[3], 0.0); + let o3 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2, 0.0, env2); + assert_eq!(o2.img.data, o3.img.data); + } + + #[test] + fn unmult_black_becomes_transparent() { + let mut img = Image::filled(4, 4, [0.0, 0.0, 0.0, 1.0]); + img.set(1, 1, [0.5, 0.25, 0.0, 1.0]); + let out = run_fx("ec.key.unmult", &[], img, 0.0, EffectEnv::default()); + assert_eq!(out.img.get(0, 0), [0.0; 4]); + let p = out.img.get(1, 1); + assert!((p[3] - 0.5).abs() < 1e-6 && (p[0] - 0.5).abs() < 1e-6); + } + + #[test] + fn wire_removal_hides_a_wire() { + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + for style in 0..4u32 { + let out = run_fx( + "ec.key.ccsimplewireremoval", + &[("pointA", Value::Vec2([10.5, 0.0])), ("pointB", Value::Vec2([10.5, 20.0])), ("removalStyle", Value::Enum(style)), ("thickness", num(3.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "style {style}: {:?}", out.img.get(10, 10)); + assert_eq!(out.img.get(2, 10), img.get(2, 10)); + } + } } From b58167d0c7b415e42bff5d39174fec5ad93f0ed4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 23/32] M9.3: Noise & Grain effects (Match Grain, Median (Legacy), Curl Noise) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/noise2.rs | 368 ++++++++++++++++++++++++++++++++++- 1 file changed, 365 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index bb1953bcc..c53ada322 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -1,7 +1,369 @@ -//! Noise & Grain effects, batch 2. +//! Noise & Grain effects, batch 2: Match Grain (grain statistics measured from another layer +//! and synthesised onto this one), Median (Legacy) and Curl Noise (divergence-free flow +//! distortion from the curl of a fractal noise potential). -use crate::EffectSpec; +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::noise::{fbm, median_image}; +use crate::util::{Plane, gauss_plane, layer_or_self, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +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 } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- Match Grain + +/// Grain statistics of an image: per-channel standard deviation of the high-pass residual +/// (over opaque pixels) and a grain size from the lag-1 autocorrelation. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct GrainStats { + pub std: [f32; 3], + pub size: f32, +} + +pub(crate) fn grain_stats(img: &Image) -> GrainStats { + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + let straight: Vec<([f32; 3], f32)> = img.data.par_iter().map(|&p| unpremul(p)).collect(); + let mut std = [0.0f32; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + for (k, s) in std.iter_mut().enumerate() { + let pl = Plane { w, h, data: straight.iter().map(|(c, _)| c[k]).collect() }; + let low = gauss_plane(&pl, 1.5, 1.5); + let hp: Vec = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect(); + let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64); + for (i, v) in hp.iter().enumerate() { + if straight[i].1 > 0.5 { + sum += *v as f64; + sq += (*v as f64) * (*v as f64); + n += 1.0; + } + } + if n > 1.0 { + let mean = sum / n; + *s = ((sq / n - mean * mean).max(0.0)).sqrt() as f32; + } + for y in 0..h { + for x in 0..w - 1 { + let i = y * w + x; + if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 { + rho_num += (hp[i] * hp[i + 1]) as f64; + rho_den += (hp[i] * hp[i]) as f64; + } + } + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + +fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { + if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { + return b; + } + let view = ctx.params.e("viewingMode"); + let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); + let src_stats = grain_stats(&source); + let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; + let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let intensity = ctx.params.f("intensity") as f32; + let chan = [ctx.params.f("redIntensity") as f32, ctx.params.f("greenIntensity") as f32, ctx.params.f("blueIntensity") as f32]; + let amp: [f32; 3] = [0, 1, 2].map(|k| { + let s = src_stats.std[k]; + let e = own.std[k] * comp; + (s * s - e * e).max(0.0).sqrt() * intensity * chan[k] + }); + let size = (src_stats.size.max(0.5) * ctx.params.f("size") as f32 * b.scale as f32).max(0.05); + let aspect = ctx.params.f("aspectRatio").max(0.05) as f32; + let soft = ctx.params.f("softness") * b.scale; + let mono = ctx.params.b("monochromatic"); + let sat = ctx.params.f("saturation") as f32; + let blend_mode = ctx.params.e("blendingMode"); + let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37); + let ft = ctx.time * 24.0 * ctx.params.f("animationSpeed"); + let frame = if ctx.params.b("animateSmoothly") { ft as f32 } else { ft.floor() as f32 }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nch = if mono { 1 } else { 3 }; + let mut planes: Vec = (0..nch) + .map(|k| { + let mut pl = Plane::new(w, h); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 131)) - 0.5; + } + }); + pl + }) + .collect(); + if soft > 0.05 { + planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect(); + } + // Normalise each synthetic plane to unit standard deviation so the measured stats transfer. + for pl in planes.iter_mut() { + let n = pl.data.len().max(1) as f64; + let mean = pl.data.iter().map(|&v| v as f64).sum::() / n; + let var = pl.data.iter().map(|&v| (v as f64 - mean).powi(2)).sum::() / n; + let k = if var > 1e-12 { 1.0 / var.sqrt() } else { 0.0 }; + *pl = pl.map(|v| ((v as f64 - mean) * k) as f32); + } + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; + let gm = (g[0] + g[1] + g[2]) / 3.0; + g = g.map(|v| gm + (v - gm) * sat); + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let s = 1.0 - smoothstep(0.0, 0.5, l); + let hi = smoothstep(0.5, 1.0, l); + let weight = ws * s + wm * (1.0 - s - hi) + wh * hi; + match view { + 1 => { + let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0)); + *px = [o[0], o[1], o[2], 1.0]; + } + 2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], + _ => { + if a <= 0.0 { + return; + } + let o = [0, 1, 2].map(|k| { + let n = g[k] * amp[k] * weight; + let v = match blend_mode { + 1 => c[k] * (1.0 + n * 2.0), + 2 => 1.0 - (1.0 - c[k]) * (1.0 - n * 2.0), + _ => c[k] + n, + }; + v.max(0.0) + }); + *px = premul(o, a); + } + } + }); + b +} + +// ---------------------------------------------------------------- Median (Legacy) + +fn median_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + if ctx.params.b("operateOnAlphaChannel") { + b.img = median_image(&b.img, r); + return b; + } + // Colour only: median of straight colour, alpha kept. + let mut straight = b.img.clone(); + straight.data.par_iter_mut().for_each(|px| { + let (c, _) = unpremul(*px); + *px = [c[0], c[1], c[2], 1.0]; + }); + let m = median_image(&straight, r); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, q)| { + *px = premul([q[0], q[1], q[2]], px[3]); + }); + b +} + +// ---------------------------------------------------------------- Curl Noise + +fn curl_noise(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("displacementAmount") * b.scale; + let view = ctx.params.e("view"); + if amount.abs() < 1e-6 && view == 0 { + return b; + } + let scale = (ctx.params.f("scale").max(1.0) * b.scale) as f32; + let complexity = ctx.params.f("complexity") as f32; + let rot = ctx.params.f("rotation").to_radians() as f32; + let off = ctx.params.v2("offset"); + let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32); + let evo = (ctx.params.f("evolution") / 360.0) as f32; + let seed = ctx.params.f("randomSeed") as i64 as u32 ^ 0x51_7cc1; + let steps = ctx.params.f("steps").clamp(1.0, 16.0) as usize; + let edge_wrap = ctx.params.e("edgeBehavior") == 1; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let (cr, sr) = (rot.cos(), rot.sin()); + // Potential field on the pixel grid. + let mut pot = Plane::new(w, h); + pot.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy); + let u = (dx * cr + dy * sr) / scale; + let vv = (-dx * sr + dy * cr) / scale; + *v = fbm(u, vv, evo * 4.0, seed, complexity); + } + }); + // Curl: v = (dψ/dy, -dψ/dx), normalised so the typical speed is ~1 px per unit amount. + let mut vx = Plane::new(w, h); + let mut vy = Plane::new(w, h); + vx.data.par_chunks_mut(w.max(1)).zip(vy.data.par_chunks_mut(w.max(1))).enumerate().for_each(|(y, (rx, ry))| { + for x in 0..w { + let (xi, yi) = (x as i64, y as i64); + let gx = (pot.get_clamped(xi + 1, yi) - pot.get_clamped(xi - 1, yi)) * 0.5; + let gy = (pot.get_clamped(xi, yi + 1) - pot.get_clamped(xi, yi - 1)) * 0.5; + rx[x] = gy * scale; + ry[x] = -gx * scale; + } + }); + if view == 1 { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (a, c) = (vx.data[i], vy.data[i]); + *px = [(0.5 + a * 0.5).clamp(0.0, 1.0), (0.5 + c * 0.5).clamp(0.0, 1.0), 0.5, 1.0]; + }); + return b; + } + let src = b.img.clone(); + let dt = amount as f32 / steps as f32; + let (wf, hf) = (w as f64, h as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (mut sx, mut sy) = (x as f64 + 0.5, y as f64 + 0.5); + for _ in 0..steps { + // Trace the streamline backwards (semi-Lagrangian advection). + let (u, v) = (vx.sample(sx, sy), vy.sample(sx, sy)); + sx -= (u * dt) as f64; + sy -= (v * dt) as f64; + } + *px = if edge_wrap { src.sample_bilinear_clamped(sx.rem_euclid(wf), sy.rem_euclid(hf)) } else { src.sample_bilinear_clamped(sx, sy) }; + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + let mult = || slider(0.0, 10.0, 0.0, 2.0, 2); + vec![ + spec( + "ec.noise.matchgrain", + "Match Grain", + vec![ + p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), + p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), + p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)), + p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)), + p("redIntensity", "Red Intensity", num(1.0), mult()), + p("greenIntensity", "Green Intensity", num(1.0), mult()), + p("blueIntensity", "Blue Intensity", num(1.0), mult()), + p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox), + p("saturation", "Saturation", num(1.0), mult()), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Multiply", "Screen"])), + p("shadows", "Shadows", num(1.0), mult()), + p("midtones", "Midtones", num(1.0), mult()), + p("highlights", "Highlights", num(1.0), mult()), + p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("animateSmoothly", "Animate Smoothly", Value::Bool(true), ParamUi::Checkbox), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + match_grain, + ), + spec( + "ec.noise.medianlegacy", + "Median (Legacy)", + vec![ + p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 20.0, 0)), + p("operateOnAlphaChannel", "Operate On Alpha Channel", Value::Bool(false), ParamUi::Checkbox), + ], + median_legacy, + ), + spec( + "ec.noise.curlnoise", + "Curl Noise", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Flow Field"])), + p("displacementAmount", "Displacement Amount", num(20.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("complexity", "Complexity", num(3.0), slider(1.0, 20.0, 1.0, 10.0, 1)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + p("steps", "Flow Steps", num(4.0), slider(1.0, 16.0, 1.0, 16.0, 0)), + p("edgeBehavior", "Edge Behavior", Value::Enum(0), popup(&["Clamp", "Wrap"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + curl_noise, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel2::tests::FakeHost; + use crate::{EffectEnv, run_fx}; + + fn noisy(w: u32, h: u32, amp: f32, seed: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let n = (crate::util::hash1(x, y, seed) - 0.5) * 2.0 * amp; + img.set(x, y, [0.5 + n, 0.5 + n, 0.5 + n, 1.0]); + } + } + img + } + + #[test] + fn match_grain_transfers_grain_strength() { + let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); + let none = run_fx("ec.noise.matchgrain", &[], flat.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flat.data); + let host = FakeHost(noisy(48, 48, 0.1, 3)); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat.clone(), 0.5, env); + let target = grain_stats(&host.0).std[0]; + let got = grain_stats(&out.img).std[0]; + assert!(got > target * 0.4 && got < target * 2.5, "target {target} got {got}"); + let again = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat, 0.5, env); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn median_legacy_radius_zero_identity_and_removes_speck() { + let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); + img.set(4, 4, [1.0, 1.0, 1.0, 1.0]); + let id = run_fx("ec.noise.medianlegacy", &[("radius", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let out = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(4, 4)[0] - 0.2).abs() < 0.01); + let again = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img, 0.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn curl_noise_zero_is_identity_and_flow_preserves_flat() { + let img = crate::channel2::tests::sample(); + let id = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let flat = Image::filled(16, 16, [0.3, 0.6, 0.9, 1.0]); + let out = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], flat.clone(), 0.0, EffectEnv::default()); + assert!(out.img.data.iter().all(|p| (p[1] - 0.6).abs() < 1e-5)); + let mut grad = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + grad.set(x, y, [x as f32 / 32.0, y as f32 / 32.0, 0.0, 1.0]); + } + } + let a = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.data.iter().zip(grad.data.iter()).any(|(p, q)| (p[0] - q[0]).abs() > 0.01)); + } } From 2c4346a15e4e1ef81db0c896f04a826af367f2ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:59:05 -0400 Subject: [PATCH 24/32] M9.3: Color Correction effects (Change Color, Video Limiter, CC Color Neutralizer, PS Arbitrary Map, Lumetri Color) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color3.rs | 574 ++++++++++++++++++++++++++++++++++- 1 file changed, 571 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index cc39ceb90..768407747 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -1,7 +1,575 @@ -//! Color Correction effects, batch 3. +//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer, +//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels, +//! Vignette). -use crate::EffectSpec; +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, layer_rect, lerp, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn bipolar() -> ParamUi { + slider(-100.0, 100.0, -100.0, 100.0, 1) +} + +/// Apply `f(straight colour) -> straight colour` to every pixel, keeping alpha. +fn map_colour(b: &mut Buf, f: impl Fn(usize, [f32; 3]) -> [f32; 3] + Sync) { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + *px = premul(f(i, c), a); + }); +} + +// ---------------------------------------------------------------- Change Color + +/// Similarity mask (1 = matches) of colour `c` to `key`. +#[inline] +fn colour_match(c: [f32; 3], key: [f32; 3], using: u32, tol: f32, soft: f32) -> f32 { + let d = match using { + 0 => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(), + 1 => { + let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]); + let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]); + let dh = (h1 - h2).abs(); + let dh = dh.min(1.0 - dh) * 2.0; + // Greys have no reliable hue. + if s1 < 0.02 { 1.0 } else { dh } + } + _ => { + let chroma = |c: [f32; 3]| { + let s = (c[0] + c[1] + c[2]).max(1e-6); + [c[0] / s, c[1] / s] + }; + let (a, k) = (chroma(c), chroma(key)); + ((a[0] - k[0]).powi(2) + (a[1] - k[1]).powi(2)).sqrt() * 1.5 + } + }; + if d <= tol { + 1.0 + } else if soft <= 1e-6 { + 0.0 + } else { + (1.0 - (d - tol) / soft).clamp(0.0, 1.0) + } +} + +fn change_color(ctx: &EffectCtx, mut b: Buf) -> Buf { + let view_mask = ctx.params.e("view") == 1; + let dh = (ctx.params.f("hueTransform") / 360.0) as f32; + let dl = (ctx.params.f("lightnessTransform") / 100.0) as f32; + let ds = (ctx.params.f("saturationTransform") / 100.0) as f32; + let k = ctx.params.color("colorToChange"); + let key = [k[0], k[1], k[2]]; + let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32; + let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32; + let using = ctx.params.e("matchColors"); + let invert = ctx.params.b("invertColorCorrectionMask"); + if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 { + return b; + } + map_colour(&mut b, |_, c| { + let mut m = colour_match(c, key, using, tol, soft); + if invert { + m = 1.0 - m; + } + if view_mask { + return [m; 3]; + } + if m <= 0.0 { + return c; + } + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let h2 = (h + dh).rem_euclid(1.0); + let s2 = (s + ds * if ds > 0.0 { 1.0 - s } else { s }).clamp(0.0, 1.0); + let l2 = (l + dl * if dl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0); + let (r, g, bb) = hsl_to_rgb(h2, s2, l2); + [lerp(c[0], r, m), lerp(c[1], g, m), lerp(c[2], bb, m)] + }); + b +} + +// ---------------------------------------------------------------- Video Limiter + +const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; + +/// Soft knee: values above `start` approach `limit` smoothly. +#[inline] +fn knee(v: f32, start: f32, limit: f32) -> f32 { + if v <= start || limit <= start { + return v.min(limit); + } + let r = limit - start; + start + r * (1.0 - (-(v - start) / r).exp()) +} + +fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = CLIP_LEVELS[(ctx.params.e("clipLevel") as usize).min(CLIP_LEVELS.len() - 1)]; + let method = ctx.params.e("clipMethod"); + let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(ctx.params.e("compressionBeforeClipping") as usize).min(4)]; + let warn = ctx.params.b("gamutWarning"); + let wc = ctx.params.color("gamutWarningColor"); + let start = level * (1.0 - comp); + map_colour(&mut b, |_, c| { + let y = luminance(c[0], c[1], c[2]); + let mut o = c; + // Luma: compress luminance, keep chroma offsets. + if method != 1 { + let y2 = knee(y.max(0.0), start, level); + o = [o[0] - y + y2, o[1] - y + y2, o[2] - y + y2]; + } + // Chroma: scale chroma towards luma until every channel is within 0..level. + if method != 0 { + let yl = luminance(o[0], o[1], o[2]); + let mut t = 1.0f32; + for &v in &o { + if v > level && v - yl > 1e-6 { + t = t.min((level - yl) / (v - yl)); + } + if v < 0.0 && yl - v > 1e-6 { + t = t.min(yl / (yl - v)); + } + } + let t = t.clamp(0.0, 1.0); + o = [yl + (o[0] - yl) * t, yl + (o[1] - yl) * t, yl + (o[2] - yl) * t]; + } + let o = o.map(|v| v.clamp(0.0, level)); + if warn && (c.iter().zip(o.iter()).any(|(a, b)| (a - b).abs() > 1e-4)) { + return [wc[0], wc[1], wc[2]]; + } + o + }); + b +} + +// ---------------------------------------------------------------- CC Color Neutralizer + +fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; + let off: Vec<[f32; 3]> = pairs + .iter() + .map(|(u, bal)| { + let (u, v) = (ctx.params.color(u), ctx.params.color(bal)); + [v[0] - u[0], v[1] - u[1], v[2] - u[2]] + }) + .collect(); + let pin = (ctx.params.f("pinning") / 100.0) as f32; + let orig = (ctx.params.f("blendWOriginal") / 100.0) as f32; + let contrast = (ctx.params.f("contrast") / 100.0) as f32; + let darks = (ctx.params.f("darks") / 100.0) as f32; + let brights = (ctx.params.f("brights") / 100.0) as f32; + map_colour(&mut b, |_, c| { + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let ws = 1.0 - smoothstep(0.0, 0.5, l); + let wh = smoothstep(0.5, 1.0, l); + let wm = 1.0 - ws - wh; + // Pinning keeps pure black and pure white fixed. + let keep = 1.0 - pin * (1.0 - 4.0 * l * (1.0 - l)).clamp(0.0, 1.0); + let mut o = [0.0; 3]; + for k in 0..3 { + let d = off[0][k] * ws + off[1][k] * wm + off[2][k] * wh; + let mut v = c[k] + d * keep; + v = 0.5 + (v - 0.5) * (1.0 + contrast); + v += darks * 0.25 * (1.0 - l).powi(2) + brights * 0.25 * l * l; + o[k] = v.max(0.0); + } + [lerp(o[0], c[0], orig), lerp(o[1], c[1], orig), lerp(o[2], c[2], orig)] + }); + b +} + +// ---------------------------------------------------------------- PS Arbitrary Map + +/// Parse a map string of 256 comma/space separated values (0..255, or 0..1 floats). An empty or +/// malformed map is the identity. +fn parse_map(s: &str) -> Vec { + let vals: Vec = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::().ok()).collect(); + if vals.len() < 2 { + return (0..256).map(|i| i as f32 / 255.0).collect(); + } + let max = vals.iter().cloned().fold(0.0f32, f32::max); + let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 }; + vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() +} + +fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf { + let map = parse_map(ctx.params.s("map")); + let phase = ctx.params.f("phase"); + let alpha = ctx.params.b("applyPhaseMapToAlpha"); + let n = map.len(); + let lookup = |v: f32| -> f32 { + let x = (v.clamp(0.0, 1.0) as f64 * (n - 1) as f64 + phase * (n - 1) as f64 / 255.0).rem_euclid(n as f64); + let i0 = x.floor() as usize % n; + let i1 = if i0 + 1 < n { i0 + 1 } else { i0 }; + let t = (x - x.floor()) as f32; + lerp(map[i0], map[i1], t) + }; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let o = c.map(lookup); + let na = if alpha { lookup(a) } else { a }; + *px = premul(o, na); + }); + b +} + +// ---------------------------------------------------------------- Lumetri Color + +/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the +/// end points fixed. +#[inline] +fn tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 { + if s == 0.0 && m == 0.0 && h == 0.0 { + return x; + } + let t = x.clamp(0.0, 1.0); + let bump = |c: f32| { + let d = (t - c) / 0.25; + (1.0 - d * d).max(0.0).powi(2) + }; + x + 0.25 * (s * bump(0.25) + m * bump(0.5) + h * bump(0.75)) +} + +/// The colour part of a wheel/tint colour (offset from its own grey), 0 for neutral colours. +#[inline] +fn tint_offset(c: [f32; 4]) -> [f32; 3] { + let l = (c[0] + c[1] + c[2]) / 3.0; + [c[0] - l, c[1] - l, c[2] - l] +} + +fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let temp = (pr.f("temperature") / 100.0) as f32; + let tint = (pr.f("tint") / 100.0) as f32; + let expo = 2f32.powf(pr.f("exposure") as f32); + let contrast = (pr.f("contrast") / 100.0) as f32; + let (hi, sh, wh, bl) = + ((pr.f("highlights") / 100.0) as f32, (pr.f("shadows") / 100.0) as f32, (pr.f("whites") / 100.0) as f32, (pr.f("blacks") / 100.0) as f32); + let sat = (pr.f("saturation") / 100.0) as f32; + let intensity = (pr.f("lookIntensity") / 100.0) as f32; + let faded = (pr.f("fadedFilm") / 100.0) as f32; + let vib = (pr.f("vibrance") / 100.0) as f32; + let csat = (pr.f("creativeSaturation") / 100.0) as f32; + let st = tint_offset(pr.color("shadowTint")); + let ht = tint_offset(pr.color("highlightTint")); + let tbal = (pr.f("tintBalance") / 100.0) as f32; + let curve = |k: &str| { + [(pr.f(&format!("{k}Shadows")) / 100.0) as f32, (pr.f(&format!("{k}Midtones")) / 100.0) as f32, (pr.f(&format!("{k}Highlights")) / 100.0) as f32] + }; + let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue")); + let ws = tint_offset(pr.color("shadowsWheel")); + let wm = tint_offset(pr.color("midtonesWheel")); + let wh_ = tint_offset(pr.color("highlightsWheel")); + let vig = pr.f("vignetteAmount") as f32; + let vmid = (pr.f("vignetteMidpoint") / 100.0) as f32; + let vround = (pr.f("vignetteRoundness") / 100.0) as f32; + let vfeather = (pr.f("vignetteFeather") / 100.0) as f32; + let sharpen = (pr.f("sharpen") / 100.0) as f32; + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let w = b.img.width as usize; + let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5); + map_colour(&mut b, |i, c| { + let mut c = c; + // Basic Correction: white balance, exposure, contrast, tone, saturation. + if temp != 0.0 || tint != 0.0 { + c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)]; + } + if expo != 1.0 { + c = c.map(|v| v * expo); + } + if contrast != 0.0 { + c = c.map(|v| 0.5 + (v - 0.5) * (1.0 + contrast)); + } + if hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0 { + let l = luminance(c[0], c[1], c[2]).max(0.0); + let lt = l.min(1.0); + let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2); + c = c.map(|v| v + d); + } + let l = luminance(c[0], c[1], c[2]); + if sat != 1.0 { + c = c.map(|v| l + (v - l) * sat); + } + // Creative. + if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) { + let mut d = c; + if faded != 0.0 { + d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded); + } + let l = luminance(d[0], d[1], d[2]); + if vib != 0.0 { + let mx = d[0].max(d[1]).max(d[2]); + let mn = d[0].min(d[1]).min(d[2]); + let s = if mx > 1e-6 { (mx - mn) / mx } else { 0.0 }; + let k = 1.0 + vib * (1.0 - s.clamp(0.0, 1.0)); + d = d.map(|v| l + (v - l) * k); + } + if csat != 1.0 { + d = d.map(|v| l + (v - l) * csat); + } + let pivot = (0.5 + 0.25 * tbal).clamp(0.05, 0.95); + let lt = l.clamp(0.0, 1.0); + let wsh = 1.0 - smoothstep(0.0, pivot, lt); + let whi = smoothstep(pivot, 1.0, lt); + for k in 0..3 { + d[k] += 0.5 * (st[k] * wsh + ht[k] * whi); + } + c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)]; + } + // Curves. + c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2])); + c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])]; + // Color wheels. + if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] { + let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let a = 1.0 - smoothstep(0.0, 0.5, lt); + let z = smoothstep(0.5, 1.0, lt); + let m = 1.0 - a - z; + for k in 0..3 { + c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z); + } + } + // Vignette. + if vig != 0.0 { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let nx = ((x - cx) / (lw * 0.5).max(1e-6)) as f32; + let ny = ((y - cy) / (lh * 0.5).max(1e-6)) as f32; + // Roundness: 0 follows the frame's aspect, +1 a circle, -1 squarer. + let aspect = (lw / lh.max(1e-6)) as f32; + let (nx, ny) = if vround > 0.0 { (nx * lerp(1.0, aspect.max(1.0), vround), ny * lerp(1.0, (1.0 / aspect).max(1.0), vround)) } else { (nx, ny) }; + let pw = 2.0 + (-vround).max(0.0) * 6.0; + let r = (nx.abs().powf(pw) + ny.abs().powf(pw)).powf(1.0 / pw) / std::f32::consts::SQRT_2; + let start = vmid * 0.9; + let f = smoothstep(start, start + 0.05 + vfeather * (1.05 - start), r); + let k = if vig < 0.0 { 1.0 + vig * 0.2 * f } else { 1.0 }; + c = c.map(|v| if vig > 0.0 { v + (1.0 - v) * (vig * 0.2 * f).min(1.0) } else { v * k.max(0.0) }); + } + c.map(|v| v.max(0.0)) + }); + if sharpen != 0.0 { + let luma = Plane::luma(&b.img); + let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0; + *px = premul(c.map(|v| (v + d).max(0.0)), a); + }); + } + b +} pub fn specs() -> Vec { - Vec::new() + let grey = || col(0.5, 0.5, 0.5); + let mut lum = vec![ + p("inputLut", "Input LUT", Value::Enum(0), popup(&["None"])), + p("temperature", "Temperature", num(0.0), bipolar()), + p("tint", "Tint", num(0.0), bipolar()), + p("exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), + p("contrast", "Contrast", num(0.0), bipolar()), + p("highlights", "Highlights", num(0.0), bipolar()), + p("shadows", "Shadows", num(0.0), bipolar()), + p("whites", "Whites", num(0.0), bipolar()), + p("blacks", "Blacks", num(0.0), bipolar()), + p("saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("look", "Look", Value::Enum(0), popup(&["None"])), + p("lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("fadedFilm", "Faded Film", num(0.0), pct()), + p("sharpen", "Sharpen", num(0.0), bipolar()), + p("vibrance", "Vibrance", num(0.0), bipolar()), + p("creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("shadowTint", "Shadow Tint", grey(), ParamUi::Color), + p("highlightTint", "Highlight Tint", grey(), ParamUi::Color), + p("tintBalance", "Tint Balance", num(0.0), bipolar()), + ]; + const CURVES: [(&str, &str); 12] = [ + ("curveMasterShadows", "RGB Curve Shadows"), + ("curveMasterMidtones", "RGB Curve Midtones"), + ("curveMasterHighlights", "RGB Curve Highlights"), + ("curveRedShadows", "Red Curve Shadows"), + ("curveRedMidtones", "Red Curve Midtones"), + ("curveRedHighlights", "Red Curve Highlights"), + ("curveGreenShadows", "Green Curve Shadows"), + ("curveGreenMidtones", "Green Curve Midtones"), + ("curveGreenHighlights", "Green Curve Highlights"), + ("curveBlueShadows", "Blue Curve Shadows"), + ("curveBlueMidtones", "Blue Curve Midtones"), + ("curveBlueHighlights", "Blue Curve Highlights"), + ]; + for (id, name) in CURVES { + lum.push(p(id, name, num(0.0), bipolar())); + } + lum.extend([ + p("shadowsWheel", "Shadows", grey(), ParamUi::Color), + p("midtonesWheel", "Midtones", grey(), ParamUi::Color), + p("highlightsWheel", "Highlights", grey(), ParamUi::Color), + p("vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), + p("vignetteMidpoint", "Midpoint", num(50.0), pct()), + p("vignetteRoundness", "Roundness", num(0.0), bipolar()), + p("vignetteFeather", "Feather", num(50.0), pct()), + ]); + vec![ + spec( + "ec.color.changecolor", + "Change Color", + vec![ + p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])), + p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)), + p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()), + p("saturationTransform", "Saturation Transform", num(0.0), bipolar()), + p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color), + p("matchingTolerance", "Matching Tolerance", num(15.0), pct()), + p("matchingSoftness", "Matching Softness", num(0.0), pct()), + p("matchColors", "Match colors", Value::Enum(1), popup(&["Using RGB", "Using Hue", "Using Chroma"])), + p("invertColorCorrectionMask", "Invert Color Correction Mask", Value::Bool(false), ParamUi::Checkbox), + ], + change_color, + ), + spec( + "ec.color.videolimiter", + "Video Limiter", + vec![ + p("clipLevel", "Clip Level", Value::Enum(2), popup(&["90%", "95%", "100%", "105%", "109%"])), + p("clipMethod", "Clip Method", Value::Enum(2), popup(&["Luma", "Chroma", "Smart Limit"])), + p("compressionBeforeClipping", "Compression before Clipping", Value::Enum(2), popup(&["None", "3%", "5%", "10%", "20%"])), + p("gamutWarning", "Gamut Warning", Value::Bool(false), ParamUi::Checkbox), + p("gamutWarningColor", "Gamut Warning Color", col(1.0, 0.0, 0.0), ParamUi::Color), + ], + video_limiter, + ), + spec( + "ec.color.cccolorneutralizer", + "CC Color Neutralizer", + vec![ + p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color), + p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color), + p("midtonesUnbalance", "Midtones Unbalance", grey(), ParamUi::Color), + p("midtonesBalance", "Midtones Balance", grey(), ParamUi::Color), + p("highlightsUnbalance", "Highlights Unbalance", col(1.0, 1.0, 1.0), ParamUi::Color), + p("highlightsBalance", "Highlights Balance", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pinning", "Pinning", num(0.0), pct()), + p("blendWOriginal", "Blend w. Original", num(0.0), pct()), + p("darks", "Darks", num(0.0), bipolar()), + p("brights", "Brights", num(0.0), bipolar()), + p("contrast", "Contrast", num(0.0), bipolar()), + ], + color_neutralizer, + ), + spec( + "ec.color.psarbitrarymap", + "PS Arbitrary Map", + vec![ + p("phase", "Phase", num(0.0), slider(-1000.0, 1000.0, 0.0, 255.0, 0)), + p("applyPhaseMapToAlpha", "Apply Phase Map To Alpha", Value::Bool(false), ParamUi::Checkbox), + p("map", "Map", Value::Str(String::new()), ParamUi::Hidden), + ], + arbitrary_map, + ), + spec("ec.color.lumetri", "Lumetri Color", lum, lumetri), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn ramp() -> Image { + let mut img = Image::new(16, 8); + for y in 0..8 { + for x in 0..16 { + let a = if y < 7 { 1.0 } else { 0.5 }; + img.set(x, y, [x as f32 / 16.0 * a, (y as f32 / 8.0) * a, 0.3 * a, a]); + } + } + img + } + + fn close(a: &Image, b: &Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Image { + run_fx(id, vals, img, 0.0, EffectEnv::default()).img + } + + #[test] + fn change_color_zero_is_identity_and_hue_shift_moves_red() { + let img = ramp(); + assert!(close(&run("ec.color.changecolor", &[], img.clone()), &img, 0.0)); + let red = Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0]); + let out = run("ec.color.changecolor", &[("hueTransform", num(120.0))], red.clone()); + let p = out.get(1, 1); + assert!(p[1] > p[0] && p[1] > p[2], "{p:?}"); + let blue = Image::filled(4, 4, [0.1, 0.2, 0.9, 1.0]); + assert!(close(&run("ec.color.changecolor", &[("hueTransform", num(120.0))], blue.clone()), &blue, 1e-6)); + let mask = run("ec.color.changecolor", &[("view", Value::Enum(1))], red); + assert!((mask.get(0, 0)[0] - 1.0).abs() < 1e-6); + assert_eq!(out.data, run("ec.color.changecolor", &[("hueTransform", num(120.0))], Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0])).data); + } + + #[test] + fn video_limiter_keeps_legal_and_limits_hot() { + let mid = Image::filled(4, 4, [0.4, 0.5, 0.3, 1.0]); + assert!(close(&run("ec.color.videolimiter", &[], mid.clone()), &mid, 1e-6)); + let hot = Image::filled(4, 4, [1.6, 1.2, 0.9, 1.0]); + let out = run("ec.color.videolimiter", &[], hot); + assert!(out.data.iter().all(|p| p[0] <= 1.0 + 1e-6 && p[1] <= 1.0 + 1e-6)); + let warn = run("ec.color.videolimiter", &[("gamutWarning", Value::Bool(true))], Image::filled(2, 2, [1.6, 1.2, 0.9, 1.0])); + assert_eq!(warn.get(0, 0), [1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn color_neutralizer_defaults_identity_and_removes_cast() { + let img = ramp(); + assert!(close(&run("ec.color.cccolorneutralizer", &[], img.clone()), &img, 1e-6)); + let cast = Image::filled(4, 4, [0.6, 0.5, 0.4, 1.0]); + let out = run("ec.color.cccolorneutralizer", &[("midtonesUnbalance", col(0.6, 0.5, 0.4)), ("midtonesBalance", col(0.5, 0.5, 0.5))], cast); + let p = out.get(0, 0); + assert!((p[0] - p[2]).abs() < 0.1, "{p:?}"); + } + + #[test] + fn arbitrary_map_identity_and_inverting_map() { + let img = ramp(); + assert!(close(&run("ec.color.psarbitrarymap", &[], img.clone()), &img, 1e-5)); + let inv: Vec = (0..256).map(|i| (255 - i).to_string()).collect(); + let out = run("ec.color.psarbitrarymap", &[("map", Value::Str(inv.join(",")))], Image::filled(2, 2, [0.25, 0.5, 1.0, 1.0])); + let p = out.get(0, 0); + assert!((p[0] - 0.75).abs() < 0.01 && p[2].abs() < 0.01, "{p:?}"); + } + + #[test] + fn lumetri_defaults_identity_and_controls_act() { + let img = ramp(); + assert!(close(&run("ec.color.lumetri", &[], img.clone()), &img, 1e-5)); + let g = Image::filled(8, 8, [0.4, 0.4, 0.4, 1.0]); + let e = run("ec.color.lumetri", &[("exposure", num(1.0))], g.clone()); + assert!((e.get(0, 0)[0] - 0.8).abs() < 1e-5); + let warm = run("ec.color.lumetri", &[("temperature", num(50.0))], g.clone()); + assert!(warm.get(0, 0)[0] > warm.get(0, 0)[2]); + let bw = run("ec.color.lumetri", &[("saturation", num(0.0))], img.clone()); + let p = bw.get(5, 3); + assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); + let v = run("ec.color.lumetri", &[("vignetteAmount", num(-3.0))], g.clone()); + assert!(v.get(0, 0)[0] < v.get(4, 4)[0]); + let c = run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g.clone()); + assert!(c.get(0, 0)[0] > 0.4); + assert_eq!(c.data, run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g).data); + } } From 43ebf68fe0499ed18812fabc8417be22969631d8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:40 -0400 Subject: [PATCH 25/32] M9.3: Obsolete effects (Basic 3D, Gaussian Blur (Legacy)); move Luma Key, Spill Suppressor, Reduce Interlace Flicker to Obsolete Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 10 +- crates/effects/src/keying.rs | 4 +- crates/effects/src/obsolete.rs | 183 ++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index def9d2071..8ce9a840d 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -596,7 +596,15 @@ pub fn specs() -> Vec { ], vector_blur, ), - spec("ec.blur.reduceflicker", "Reduce Interlace Flicker", vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], reduce_flicker), + EffectSpec { + category: "Obsolete", + ..spec( + "ec.blur.reduceflicker", + "Reduce Interlace Flicker", + vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], + reduce_flicker, + ) + }, spec( "ec.blur.cccross", "CC Cross Blur", diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index c451c7510..6be96506c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -485,7 +485,7 @@ pub fn specs() -> Vec { spec( "ec.key.luma", "Luma Key", - "Keying", + "Obsolete", vec![ p("keyType", "Key Type", Value::Enum(1), popup(&["Key Out Brighter", "Key Out Darker", "Key Out Similar", "Key Out Dissimilar"])), p("threshold", "Threshold", num(0.0), b255()), @@ -560,7 +560,7 @@ pub fn specs() -> Vec { spec( "ec.key.spill", "Spill Suppressor", - "Keying", + "Obsolete", vec![ p("colorToSuppress", "Color To Suppress", col(0.0, 0.0, 1.0), ParamUi::Color), p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs index 331686c1a..122ec32d2 100644 --- a/crates/effects/src/obsolete.rs +++ b/crates/effects/src/obsolete.rs @@ -1,7 +1,184 @@ -//! Obsolete-category effects. +//! Obsolete-category effects: Basic 3D (tilt/swivel the layer in a simple perspective space +//! with an optional specular highlight) and Gaussian Blur (Legacy). -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::gaussian_blur; +use rayon::prelude::*; + +use crate::util::layer_rect; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Obsolete", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- Basic 3D + +fn basic_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + let swivel = ctx.params.f("swivel").to_radians(); + let tilt = ctx.params.f("tilt").to_radians(); + let dist = ctx.params.f("distanceToImage") * b.scale; + let spec_on = ctx.params.b("specularHighlight"); + let wire = ctx.params.b("drawPreviewWireframe"); + if swivel == 0.0 && tilt == 0.0 && dist == 0.0 && !spec_on && !wire { + return b; + } + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5); + // Eye on the -z axis at a focal distance comparable to a 50 mm lens. + let f = lw.max(lh).max(1.0) * 1.4; + // Plane basis after Y (swivel) then X (tilt) rotations. + let (cs, ss) = (swivel.cos(), swivel.sin()); + let (ct, st) = (tilt.cos(), tilt.sin()); + let ux = [cs, 0.0, -ss]; + let uy = [ss * st, ct, cs * st]; + let n = [ss * ct, -st, cs * ct]; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let src = b.img.clone(); + let light = { + let l = [-0.5f64, -0.6, -1.0]; + let k = 1.0 / dot(l, l).sqrt(); + l.map(|v| v * k) + }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + // Ray from the eye (0, 0, -f) through the pixel on the z = 0 plane (centre-relative). + let d = [x as f64 + 0.5 - cx, y as f64 + 0.5 - cy, f]; + let o = [0.0, 0.0, -f]; + let denom = dot(d, n); + if denom.abs() < 1e-9 { + *px = [0.0; 4]; + continue; + } + let pc = [0.0, 0.0, dist]; + let t = dot([pc[0] - o[0], pc[1] - o[1], pc[2] - o[2]], n) / denom; + if t <= 0.0 { + *px = [0.0; 4]; + continue; + } + let hit = [o[0] + d[0] * t - pc[0], o[1] + d[1] * t - pc[1], o[2] + d[2] * t - pc[2]]; + let (u, v) = (dot(hit, ux), dot(hit, uy)); + let mut c = src.sample_bilinear(cx + u, cy + v); + if spec_on && c[3] > 0.0 { + // Reflect the view direction about the normal and compare with the light. + let dl = dot(d, d).sqrt(); + let dv = d.map(|v| v / dl); + let k = 2.0 * dot(dv, n); + let r = [dv[0] - k * n[0], dv[1] - k * n[1], dv[2] - k * n[2]]; + let s = (-dot(r, light)).max(0.0).powi(40) as f32; + let a = c[3]; + for ch in c.iter_mut().take(3) { + *ch += s * a * (1.0 - *ch / a.max(1e-6)).max(0.0); + } + } + if wire { + let e = (u.abs() - lw * 0.5).abs().min((v.abs() - lh * 0.5).abs()); + if e < 0.75 && u.abs() <= lw * 0.5 + 0.75 && v.abs() <= lh * 0.5 + 0.75 { + c = [1.0, 1.0, 1.0, 1.0]; + } + } + *px = c; + } + }); + b +} + +// ---------------------------------------------------------------- Gaussian Blur (Legacy) + +fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale; + if s <= 0.0 { + return b; + } + let (kx, ky) = match ctx.params.e("blurDimensions") { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + }; + if !ctx.adjustment { + b.pad((s * 3.0).ceil() as u32); + } + b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.obsolete.basic3d", + "Basic 3D", + vec![ + p("swivel", "Swivel", num(0.0), ParamUi::Angle), + p("tilt", "Tilt", num(0.0), ParamUi::Angle), + p("distanceToImage", "Distance to Image", num(0.0), slider(-1000.0, 30000.0, -100.0, 100.0, 1)), + p("specularHighlight", "Specular Highlight", Value::Bool(false), ParamUi::Checkbox), + p("drawPreviewWireframe", "Draw Preview Wireframe", Value::Bool(false), ParamUi::Checkbox), + ], + basic_3d, + ), + spec( + "ec.obsolete.gaussianlegacy", + "Gaussian Blur (Legacy)", + vec![ + p("blurriness", "Blurriness", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), + p("blurDimensions", "Blur Dimensions", Value::Enum(0), popup(&["Horizontal and Vertical", "Horizontal", "Vertical"])), + ], + gaussian_legacy, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn ramp() -> Image { + let mut img = Image::new(20, 12); + for y in 0..12 { + for x in 0..20 { + img.set(x, y, [x as f32 / 20.0, y as f32 / 12.0, 0.5, 1.0]); + } + } + img + } + + #[test] + fn basic_3d_zero_identity_swivel_narrows_and_is_deterministic() { + let img = ramp(); + let id = run_fx("ec.obsolete.basic3d", &[], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let flat = + run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(0.0)), ("specularHighlight", Value::Bool(false))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(flat.img.data, img.data); + let sw = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(sw.img.get(0, 6)[3], 0.0, "edges uncovered after swivel"); + assert!(sw.img.get(10, 6)[3] > 0.99); + let again = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(sw.img.data, again.img.data); + let far = run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(20.0))], img, 0.0, EffectEnv::default()); + assert_eq!(far.img.get(0, 0)[3], 0.0, "farther image is smaller"); + } + + #[test] + fn gaussian_legacy_zero_identity_and_blurs() { + let mut img = Image::new(15, 15); + img.set(7, 7, [1.0, 1.0, 1.0, 1.0]); + let id = run_fx("ec.obsolete.gaussianlegacy", &[], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(4.0))], img.clone(), 0.0, EffectEnv::default()); + let total: f32 = out.img.data.iter().map(|p| p[3]).sum(); + assert!((total - 1.0).abs() < 1e-3, "{total}"); + let c = out.img.width / 2; + assert!(out.img.get(c as i64, c as i64)[3] < 0.5); + } + + #[test] + fn moved_effects_are_obsolete() { + for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] { + assert_eq!(crate::find(id).unwrap().category, "Obsolete", "{id}"); + } + } } From fb6b3aa68083ab07be74cbe655ea5a0822b64c51 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:03:37 -0400 Subject: [PATCH 26/32] M9.1: Character panel parity (V/H scale, baseline shift, stroke order, small caps); layer.setText keys for them Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 17 ++++++++++- crates/engine/src/tests.rs | 3 ++ crates/ui-egui/src/panels/text_panels.rs | 38 +++++++++++++++++------- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 6adad1015..a3cc9c772 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -110,6 +110,21 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc { if let Some(v) = b_p(p, "allCaps") { d.all_caps = v; } + if let Some(v) = b_p(p, "smallCaps") { + d.small_caps = v; + } + if let Some(v) = f_p(p, "hScale") { + d.h_scale = v.clamp(1.0, 1000.0); + } + if let Some(v) = f_p(p, "vScale") { + d.v_scale = v.clamp(1.0, 1000.0); + } + if let Some(v) = f_p(p, "baselineShift") { + d.baseline_shift = v; + } + if let Some(v) = b_p(p, "strokeOverFill") { + d.stroke_over_fill = v; + } if let Some(v) = b_p(p, "fauxBold") { d.faux_bold = v; } @@ -940,7 +955,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, fauxBold?, fauxItalic?}", + "{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}", has_layers, set_text ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4d9adc21..8b3d30a0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -168,6 +168,9 @@ fn set_text_paragraph_fill_and_leading() { assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true)); s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap(); assert_eq!(doc(&s).leading, None); + s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap(); + let d = doc(&s); + assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true)); } #[test] diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 62f02fd58..ab70a8160 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -57,16 +57,21 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(json!({"style": styles[i]})); } y += 34.0; - // Numeric fields in a 2-column grid. - let fields: [(&str, &str, f64, (f64, f64), &str); 6] = [ + // Numeric fields in AE's two-column grid. + let fields: [(&str, &str, f64, (f64, f64), &str); 8] = [ ("size", "T", doc.size, (1.0, 2000.0), " px"), ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"), ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""), - ("strokeWidth", "≡", doc.stroke_width, (0.0, 500.0), " px"), + ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"), + ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"), + ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"), ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"), - ("hScale", "↔", doc.h_scale, (1.0, 1000.0), "%"), + ("", "", 0.0, (0.0, 0.0), ""), ]; for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() { + if key.is_empty() { + continue; + } let col = i % 2; let row = i / 2; let fx = x0 + col as f32 * (w / 2.0); @@ -77,13 +82,25 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(nv) = nv && enabled { - let k = if key == "baselineShift" || key == "hScale" { "tracking" } else { key }; - if k == key { - actions.push(json!({key: nv, "merge": format!("char-{key}")})); - } + actions.push(json!({key: nv, "merge": format!("char-{key}")})); } } - y += 3.0 * 28.0 + 8.0; + // Stroke Over Fill / Fill Over Stroke (bottom-right cell). + { + let fx = x0 + w / 2.0; + let fy = y + 3.0 * 28.0; + let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0)); + let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()]; + let pop = egui::Id::new("char-stroke-order-pop"); + if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::new("char-stroke-order")).clicked() && enabled { + widgets::open_popup(ui, pop); + } + app.auto.add("character.strokeOverFill", dr, "Stroke order"); + if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) { + actions.push(json!({"strokeOverFill": i == 1})); + } + } + y += 4.0 * 28.0 + 8.0; // Fill / stroke swatches. p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Fill", Tokens::ui(11.5), t.text_dim); let fs = Rect::from_min_size(pos2(x0 + 34.0, y), vec2(26.0, 20.0)); @@ -107,7 +124,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 32.0; // Style toggles. - let toggles = [("fauxBold", "T", doc.faux_bold), ("fauxItalic", "T", doc.faux_italic), ("allCaps", "TT", doc.all_caps)]; + let toggles = + [("fauxBold", "T", doc.faux_bold), ("fauxItalic", "T", doc.faux_italic), ("allCaps", "TT", doc.all_caps), ("smallCaps", "Tт", doc.small_caps)]; for (i, (key, label, on)) in toggles.into_iter().enumerate() { let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0, y), vec2(30.0, 24.0)); let resp = ui.interact(r, egui::Id::new(("char-t", key)), Sense::click()); From 17af5c30c1f1b88ec15a33b482b541f210b88f3d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:08 -0400 Subject: [PATCH 27/32] M9.3: Generate effects (Fractal, Scribble, Stroke, Vegas, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum, Audio Waveform) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate3.rs | 1887 ++++++++++++++++++++++++++++++- 1 file changed, 1884 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index e50cf9f56..87d828673 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -1,7 +1,1888 @@ -//! Generate effects, batch 3. +//! Generate effects, batch 3: Fractal (Mandelbrot/Julia), mask-driven Scribble and Stroke, +//! Vegas (animated segments along image contours or masks), Write-on, Paint Bucket (flood +//! fill), Eyedropper Fill, CC Glue Gun, CC Threads, and the audio visualisers Audio Spectrum +//! and Audio Waveform (fed by the host's audio). +//! +//! Also home of the shared anti-aliased segment rasteriser ([`raster_segs`]) used by the text +//! effects. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- shared rasterisation + +/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Seg { + pub a: [f64; 2], + pub b: [f64; 2], + pub r: f64, + pub v: f32, +} + +/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction +/// of the radius that is fully opaque; the rest falls off smoothly. +pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { + let mut out = Plane::new(w, h); + if w == 0 || h == 0 || segs.is_empty() { + return out; + } + let mut buckets: Vec> = vec![Vec::new(); h]; + for (i, s) in segs.iter().enumerate() { + let pad = s.r + 1.0; + let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; + let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); + if y1 <= 0.0 || y0 >= h { + continue; + } + for row in buckets.iter_mut().take(y1 as usize).skip(y0) { + row.push(i as u32); + } + } + let hard = hardness.clamp(0.0, 1.0); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &i in &buckets[y] { + let s = segs[i as usize]; + let pad = s.r + 1.0; + let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; + let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); + let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); + let l2 = dx * dx + dy * dy; + let inner = s.r * hard - 0.5; + let outer = s.r + 0.5; + for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { + let px = x as f64 + 0.5; + let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); + if d >= outer { + continue; + } + let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; + if c > *o { + *o = c; + } + } + } + }); + out +} + +/// Segments along a polyline (buffer px). +pub(crate) fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { + let n = pts.len(); + if n == 1 { + out.push(Seg { a: pts[0], b: pts[0], r, v }); + } + if n < 2 { + return; + } + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); + } +} + +/// Portion `[s0, s1]` (arc length) of a polyline. +pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { + let n = pts.len(); + let mut out = Vec::new(); + if n < 2 || s1 <= s0 { + return out; + } + let segs = if closed { n } else { n - 1 }; + let mut acc = 0.0; + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let (e0, e1) = (acc, acc + l); + acc = e1; + if e1 < s0 || e0 > s1 || l <= 0.0 { + continue; + } + let at = |s: f64| { + let t = ((s - e0) / l).clamp(0.0, 1.0); + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + }; + let p0 = at(s0.max(e0)); + if out.last() != Some(&p0) { + out.push(p0); + } + out.push(at(s1.min(e1))); + } + out +} + +/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal +/// Original Image. +pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { + img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = (c * opacity * color[3]).clamp(0.0, 1.0); + let s = [color[0] * a, color[1] * a, color[2] * a, a]; + *px = match style { + 1 => s, + 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], + _ => over(*px, s), + }; + }); +} + +#[inline] +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. +pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { + let m = ctx.env.masks.get(idx.checked_sub(1)?)?; + if m.points.is_empty() { + return None; + } + Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted)) +} + +/// All masks mapped to buffer pixels. +fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { + (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() +} + +/// Chaikin corner cutting (open polyline, endpoints kept). +fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { + let mut cur = pts.to_vec(); + for _ in 0..iters { + if cur.len() < 3 { + return cur; + } + let mut nx = Vec::with_capacity(cur.len() * 2); + nx.push(cur[0]); + for w in cur.windows(2) { + let (a, b) = (w[0], w[1]); + nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + nx.push(*cur.last().unwrap_or(&[0.0; 2])); + cur = nx; + } + cur +} + +// ---------------------------------------------------------------- Fractal + +#[derive(Clone, Copy)] +struct FracSet { + julia: bool, + inverse: bool, +} + +/// Smooth escape count, or `None` inside the set. +fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { + let (mut x, mut y) = z0; + let lim2 = limit * limit; + for i in 0..max_iter { + let (nx, ny) = match power { + 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), + 4 => { + let (a, b) = (x * x - y * y, 2.0 * x * y); + (a * a - b * b, 2.0 * a * b) + } + _ => (x * x - y * y, 2.0 * x * y), + }; + x = nx + c.0; + y = ny + c.1; + let m2 = x * x + y * y; + if m2 > lim2 { + let lz = m2.ln() * 0.5; + let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); + return Some((i as f64 + 1.0 - nu).max(0.0)); + } + } + None +} + +fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let set_choice = pr.e("setChoice"); + let power = 2 + pr.e("equation").min(2); + let mc = (pr.f("mandelbrotX"), pr.f("mandelbrotY")); + let mmag = pr.f("mandelbrotMagnification"); + let mlim = pr.f("mandelbrotEscapeLimit").max(1.01); + let jc = (pr.f("juliaX"), pr.f("juliaY")); + let jmag = pr.f("juliaMagnification"); + let jlim = pr.f("juliaEscapeLimit").max(1.01); + let post = (pr.f("postInversionX"), pr.f("postInversionY")); + let overlay = pr.b("overlay"); + let transparent_inside = pr.b("transparency") || overlay; + let palette = pr.e("palette"); + let hue = (pr.f("hue") / 360.0) as f32; + let steps = pr.f("cycleSteps").max(1.0); + let cyc_off = pr.f("cycleOffset") / 360.0; + let edge = pr.b("edgeHighlight"); + let every = pr.e("samplingMethod") == 1; + let factor = pr.f("samplingFactor").round().clamp(1.0, 9.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let lw = ctx.layer_size[0].max(1.0); + let lh = ctx.layer_size[1].max(1.0); + let inv = 1.0 / b.scale.max(1e-9); + let (off_x, off_y) = (b.offset[0], b.offset[1]); + let sets: (FracSet, Option) = match set_choice { + 1 => (FracSet { julia: false, inverse: true }, None), + 2 => (FracSet { julia: false, inverse: false }, Some(FracSet { julia: false, inverse: true })), + 3 => (FracSet { julia: false, inverse: true }, Some(FracSet { julia: false, inverse: false })), + 4 => (FracSet { julia: true, inverse: false }, None), + 5 => (FracSet { julia: true, inverse: true }, None), + _ => (FracSet { julia: false, inverse: false }, None), + }; + let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; + let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { + let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; + let span = 3.5 / 2f64.powf(mag); + let mut u = (lx - lw * 0.5) / lw * span + center.0; + let mut v = (ly - lh * 0.5) / lw * span + center.1; + if s.inverse { + let d = (u * u + v * v).max(1e-12); + u = u / d + post.0; + v = -v / d + post.1; + } + if s.julia { escape((u, v), mc, power, lim, iter_for(mag)) } else { escape((0.0, 0.0), (u, v), power, lim, iter_for(mag)) } + }; + let eval = |lx: f64, ly: f64| -> Option { + let a = eval_set(sets.0, lx, ly); + match (a, sets.1) { + (None, Some(s2)) => eval_set(s2, lx, ly), + _ => a, + } + }; + let shade = |n: Option| -> Px { + let Some(n) = n else { + return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; + }; + let band = ((n / steps + cyc_off).rem_euclid(1.0)) as f32; + let (r, g, bl) = match palette { + 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), + 2 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.3 + 0.4 * (band * 4.0).fract()), + _ => hsl_to_rgb(hue, 0.85, band.clamp(0.0, 1.0)), + }; + let mut c = [r, g, bl]; + if edge && n.fract() < 0.12 { + c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; + } + [c[0], c[1], c[2], 1.0] + }; + // First pass: one sample per pixel. + let mut first: Vec> = vec![None; w * h]; + first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); + } + }); + let src = if overlay { Some(b.img.clone()) } else { None }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let here = first[y * w + x]; + let needs_ss = factor > 1 + && (every || { + let mut diff = false; + for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let o = first[ny as usize * w + nx as usize]; + diff |= match (here, o) { + (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, + (None, None) => false, + _ => true, + }; + } + diff + }); + let c = if needs_ss { + let mut acc = [0.0f32; 4]; + for sy in 0..factor { + for sx in 0..factor { + let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; + let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; + let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); + let a = s[3]; + for k in 0..3 { + acc[k] += s[k] * a; + } + acc[3] += a; + } + } + let n = (factor * factor) as f32; + [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] + } else { + let s = shade(here); + [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] + }; + *px = match &src { + Some(o) => over(o.data[y * w + x], c), + None => c, + }; + } + }); + b +} + +// ---------------------------------------------------------------- Scribble + +/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. +#[allow(clippy::too_many_arguments)] +fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { + out.clear(); + for (pts, _closed, inverted) in polys { + let m = pts.len(); + if m < 2 { + continue; + } + // Crossings: (t along d, |sin| of crossing angle). + let mut xs: Vec<(f64, f64)> = Vec::new(); + for i in 0..m { + let (a, b) = (pts[i], pts[(i + 1) % m]); + let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); + if (na > 0.0) == (nb > 0.0) { + continue; + } + let t = na / (na - nb); + let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let el = (ex * ex + ey * ey).sqrt().max(1e-9); + let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); + xs.push((q[0] * d[0] + q[1] * d[1], sin)); + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Signed area decides which side is "left". + let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); + let left_is_inside = area < 0.0; + let mode = match fill { + 4 => { + if left_is_inside { + 2 + } else { + 3 + } + } + 5 => { + if left_is_inside { + 3 + } else { + 2 + } + } + f => f, + }; + let mode = if *inverted && mode == 0 { 6 } else { mode }; + for (k, &(t, sin)) in xs.iter().enumerate() { + let entering = k % 2 == 0; + let l = ew / sin; + match mode { + 0 => { + if entering && k + 1 < xs.len() { + out.push((t, xs[k + 1].0)); + } + } + 1 => out.push((t - l * 0.5, t + l * 0.5)), + 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), + 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), + _ => {} + } + } + if mode == 6 { + // Inverted Inside: outside of the polygon within the bounds. + let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; + let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; + let mut cur = lo; + for pair in xs.chunks(2) { + out.push((cur, pair[0].0)); + cur = pair.get(1).map(|v| v.0).unwrap_or(hi); + } + out.push((cur, hi)); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Merge overlaps. + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); + for &(a, b) in out.iter() { + if let Some(last) = merged.last_mut() + && a <= last.1 + { + last.1 = last.1.max(b); + continue; + } + merged.push((a, b)); + } + *out = merged; +} + +fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let which = pr.e("scribble"); + let polys: Vec<_> = if which == 0 { mask_px(ctx, &b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, &b) }; + let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); + let style = match pr.e("composite") { + 0 => 0, + 2 => 2, + _ => 1, + }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if polys.is_empty() { + if style == 1 { + b.img = Image::new(b.img.width, b.img.height); + } + return b; + } + let sc = b.scale; + let fill = pr.e("fillType"); + let ew = pr.f("edgeWidth").max(0.1) * sc; + let ang = pr.f("angle").to_radians(); + let d = [ang.cos(), ang.sin()]; + let n = [-d[1], d[0]]; + let width = (pr.f("strokeWidth") * sc).max(0.1); + let curv = pr.f("curviness") / 100.0; + let curv_var = pr.f("curvinessVariation") / 100.0; + let spacing = (pr.f("spacing") * sc).max(0.75).max(width * 0.25); + let spacing_var = pr.f("spacingVariation") / 100.0; + let overlap = pr.f("pathOverlap") / 100.0; + let overlap_var = pr.f("pathOverlapVariation") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let sequential = pr.b("fillPathsSequentially"); + let wiggle = pr.e("wiggleType"); + let wps = pr.f("wigglesPerSecond").max(0.0); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let (step, frac) = match wiggle { + 0 => (0u32, 0.0f32), + 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), + _ => { + let u = (ctx.time * wps).max(0.0); + (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) + } + }; + let rnd = |i: u32, k: u32| { + let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; + if frac > 0.0 { + let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; + a + (c - a) * frac + } else { + a + } + }; + let groups: Vec, bool, bool)>> = + if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; + // Per group a zig-zag polyline (in buffer px). + let mut lines: Vec>> = Vec::new(); + let mut runs = Vec::new(); + for (gi, g) in groups.iter().enumerate() { + let mut nmin = f64::INFINITY; + let mut nmax = f64::NEG_INFINITY; + for (pts, _, _) in g { + for q in pts { + let v = q[0] * n[0] + q[1] * n[1]; + nmin = nmin.min(v); + nmax = nmax.max(v); + } + } + nmin -= ew; + nmax += ew; + // Chains of zig-zags: each run joins the nearest chain that ended on the previous + // scan line (so separate regions get separate strokes). + let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); + let mut s = nmin + spacing * 0.5; + let mut li = 0u32; + let reach = spacing * 3.0 + ew; + while s < nmax && li < 20_000 { + scan_runs(g, fill, ew, d, n, s, &mut runs); + for (ri, &(a, bb)) in runs.iter().enumerate() { + let k = (gi as u32) << 20 | ri as u32; + let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); + let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); + let (a, bb) = (a - ov, bb + ov2); + if bb <= a { + continue; + } + let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; + let (pa, pb) = (at(a), at(bb)); + let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); + let best = chains + .iter() + .enumerate() + .filter(|(_, c)| li > 0 && c.1 == li - 1) + .map(|(i, c)| { + let last = *c.0.last().unwrap_or(&pa); + (i, dist(last, pa).min(dist(last, pb))) + }) + .filter(|&(_, dd)| dd < reach) + .min_by(|x, y| x.1.total_cmp(&y.1)); + match best { + Some((ci, _)) => { + let last = *chains[ci].0.last().unwrap_or(&pa); + let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; + // Curved turn: an extra point pushed past the edge. + let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); + let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; + let ch = &mut chains[ci]; + ch.0.extend([mid, p0, p1]); + ch.1 = li; + } + None => chains.push((vec![pa, pb], li)), + } + } + s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); + li += 1; + } + lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); + } + let r = width * 0.5; + let mut segs = Vec::new(); + for group in &lines { + let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); + let total: f64 = lens.iter().sum(); + let (g0, g1) = (total * start, total * end); + let mut acc = 0.0; + for (l, len) in group.iter().zip(lens) { + let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)); + acc += len; + poly_segs(&part, false, r, 1.0, &mut segs); + } + } + let cov = raster_segs(w, h, &segs, 0.9); + let color = pr.color("color"); + paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Stroke + +fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let all = pr.b("allMasks"); + let polys: Vec<_> = if all { all_masks_px(ctx, &b) } else { mask_px(ctx, &b, pr.f("path").round() as usize).into_iter().collect() }; + let style = pr.e("paintStyle"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let size = (pr.f("brushSize") * b.scale).max(0.1); + let r = size * 0.5; + let hard = pr.f("brushHardness") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let spacing = pr.f("spacing") / 100.0; + let sequential = pr.b("strokeSequentially"); + let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); + let total: f64 = lens.iter().sum(); + let mut segs = Vec::new(); + let mut acc = 0.0; + for ((pts, closed, _), &len) in polys.iter().zip(&lens) { + let (s0, s1) = if all && sequential { + let (g0, g1) = (total * start, total * end); + ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) + } else { + (len * start, len * end) + }; + acc += len; + if s1 <= s0 { + continue; + } + if spacing <= 0.5 { + let part = trim_poly(pts, *closed, s0, s1); + poly_segs(&part, false, r, 1.0, &mut segs); + } else { + let step = (size * spacing).max(0.5); + let mut s = s0; + while s <= s1 + 1e-9 && segs.len() < 200_000 { + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + segs.push(Seg { a: q, b: q, r, v: 1.0 }); + s += step; + } + } + } + let cov = raster_segs(w, h, &segs, hard); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Vegas + +/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). +fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { + use std::collections::HashMap; + let (w, h) = (pl.w, pl.h); + if w < 2 || h < 2 { + return Vec::new(); + } + // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. + let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; + let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; + let mut pos: HashMap = HashMap::new(); + let mut adj: HashMap> = HashMap::new(); + let v = |x: usize, y: usize| pl.data[y * w + x]; + let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; + for y in 0..h - 1 { + for x in 0..w - 1 { + let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); + let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; + if idx == 0 || idx == 15 { + continue; + } + // Edge points (pixel centres at +0.5). + let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); + let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); + let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); + let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); + let pairs: &[(usize, usize)] = match idx { + 1 | 14 => &[(3, 0)], + 2 | 13 => &[(0, 1)], + 3 | 12 => &[(3, 1)], + 4 | 11 => &[(1, 2)], + 5 => &[(3, 0), (1, 2)], + 6 | 9 => &[(0, 2)], + 7 | 8 => &[(3, 2)], + 10 => &[(0, 1), (2, 3)], + _ => &[], + }; + let e = [top, right, bottom, left]; + for &(i, j) in pairs { + pos.insert(e[i].0, e[i].1); + pos.insert(e[j].0, e[j].1); + adj.entry(e[i].0).or_default().push(e[j].0); + adj.entry(e[j].0).or_default().push(e[i].0); + } + } + } + let mut keys: Vec = adj.keys().copied().collect(); + keys.sort_unstable(); + // Start open chains at endpoints (degree 1) first, then closed loops. + keys.sort_by_key(|k| adj[k].len() != 1); + let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); + let mut out = Vec::new(); + for &k0 in &keys { + for &first in &adj[&k0] { + if used.contains(&(k0.min(first), k0.max(first))) { + continue; + } + let mut chain = vec![pos[&k0]]; + let (mut prev, mut cur) = (k0, first); + used.insert((prev.min(cur), prev.max(cur))); + let mut closed = false; + loop { + chain.push(pos[&cur]); + if cur == k0 { + closed = true; + chain.pop(); + break; + } + let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); + match next { + Some(n) => { + used.insert((cur.min(n), cur.max(n))); + prev = cur; + cur = n; + } + None => break, + } + } + if chain.len() >= 2 { + out.push((chain, closed)); + } + } + } + out +} + +fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let sc = b.scale; + let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { + mask_px(ctx, &b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() + } else { + let src = layer_or_self(ctx, &b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); + let ch = pr.e("channel"); + let inv = pr.b("invertInput"); + let mut pl = Plane::from_image(&src, |px| { + let (c, a) = unpremul(px); + let v = match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if inv { 1.0 - v } else { v } + }); + let blur = pr.f("preBlur") * sc; + if blur > 0.05 { + pl = gauss_plane(&pl, blur, blur); + } + let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); + // Simplify by tolerance (drop points closer than tol × size). + let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; + if tol > 0.0 { + for (c, _) in cs.iter_mut() { + let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); + for &q in c.iter() { + if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { + keep.push(q); + } + } + *c = keep; + } + } + cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); + if pr.e("renderMode") == 1 { + let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; + cs.into_iter().skip(sel).take(1).collect() + } else { + cs + } + }; + let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; + let seg_len = pr.f("length").clamp(0.0, 1.0); + let bunched = pr.e("segmentDistribution") == 0; + let rot = pr.f("rotation") / 360.0; + let random_phase = pr.b("randomPhase"); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let width = (pr.f("width") * sc).max(0.1); + let hard = pr.f("hardness").clamp(0.0, 1.0); + let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); + let mut segs = Vec::new(); + for (ci, (pts, closed)) in paths.iter().enumerate() { + let len = poly_length(pts, *closed); + if len <= 0.0 { + continue; + } + let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; + let each = len / nseg as f64 * seg_len; + for i in 0..nseg { + let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; + let s0 = (base + phase).rem_euclid(1.0) * len; + // Sub-sample the segment so opacity can ramp along it. + let sub = 12usize; + let mut prev: Option<[f64; 2]> = None; + for k in 0..=sub { + let f = k as f64 / sub as f64; + let mut s = s0 + each * f; + if *closed { + s = s.rem_euclid(len); + } else if s > len { + break; + } + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; + if let Some(pv) = prev + && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 + { + segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); + } + prev = Some(q); + } + } + } + let cov = raster_segs(w, h, &segs, hard); + let color = pr.color("color"); + match pr.e("blendMode") { + 1 => paint(&mut b.img, &cov, color, 1.0, 0), + 2 => { + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = c * color[3]; + *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); + }); + } + 3 => paint(&mut b.img, &cov, color, 1.0, 2), + _ => paint(&mut b.img, &cov, color, 1.0, 1), + } + b +} + +// ---------------------------------------------------------------- Write-on / Glue Gun + +/// Brush dab history: only the brush position at the current time is known to an effect +/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along +/// the current position — a single dab when the brush is static. Deterministic. +fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let q = b.to_px(pr.v2("brushPosition")); + let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let cov = raster_segs(w, h, &[Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], pr.f("brushHardness") / 100.0); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("brushOpacity") / 100.0) as f32, pr.e("paintStyle")); + b +} + +fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let q = b.to_px(pr.v2("brushPosition")); + let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); + let density = pr.f("density").max(0.0); + let refl = pr.f("reflection").to_radians(); + let strength = (pr.f("strength") / 100.0) as f32; + let glue_only = pr.e("style") == 1; + let src = b.img.clone(); + let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; + let beads = (density.round() as usize).clamp(1, 32); + let seed = ctx.seed; + let centers: Vec<([f64; 2], f64)> = (0..beads) + .map(|i| { + let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; + let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; + ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + // Height field: union of hemispheres. + let mut best = (0.0f64, [0.0f64; 2], 1.0f64); + for &(c, r) in ¢ers { + let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); + let d2 = dx * dx + dy * dy; + if d2 < 1.0 { + let hgt = (1.0 - d2).sqrt(); + if hgt > best.0 { + best = (hgt, [dx, dy], r); + } + } + } + if best.0 <= 0.0 { + if glue_only { + *px = [0.0; 4]; + } + continue; + } + let nrm = [best.1[0], best.1[1], best.0]; + let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); + let spec = ndl.powi(24) as f32; + // Refraction: look through the bead. + let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); + let edge = smoothstep(0.0, 0.15, best.0 as f32); + let shade = 0.65 + 0.35 * ndl as f32; + let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; + let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; + let base = if glue_only { [0.0; 4] } else { *px }; + *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); + px[3] = px[3].clamp(0.0, 1.0); + } + }); + b +} + +// ---------------------------------------------------------------- Paint Bucket + +fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return b; + } + let fp = b.to_px(pr.v2("fillPoint")); + let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); + let sel = pr.e("fillSelector"); + let tol = (pr.f("tolerance") / 255.0) as f32; + let key = |p: Px| -> [f32; 4] { + let (c, a) = unpremul(p); + match sel { + 1 => [c[0], c[1], c[2], 0.0], + 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], + 3 | 4 => [0.0, 0.0, 0.0, a], + _ => p, + } + }; + let mut region = Plane::new(w, h); + if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { + let seed = key(b.img.data[sy as usize * w + sx as usize]); + let within = |i: usize| { + let k = key(b.img.data[i]); + if sel == 2 { + return k[3] == seed[3]; + } + (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) + }; + let mut seen = vec![false; w * h]; + let mut stack = vec![(sx as usize, sy as usize)]; + while let Some((x, y)) = stack.pop() { + let row = y * w; + if seen[row + x] || !within(row + x) { + continue; + } + let mut l = x; + while l > 0 && !seen[row + l - 1] && within(row + l - 1) { + l -= 1; + } + let mut r = x; + while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { + r += 1; + } + for i in l..=r { + seen[row + i] = true; + region.data[row + i] = 1.0; + if y > 0 && !seen[row - w + i] { + stack.push((i, y - 1)); + } + if y + 1 < h && !seen[row + w + i] { + stack.push((i, y + 1)); + } + } + } + } + if pr.b("viewThreshold") { + b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); + return b; + } + let sc = b.scale; + let mut cov = match pr.e("stroke") { + 1 => { + let s = pr.f("featherSoftness") * sc * 0.5; + if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } + } + 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), + 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), + 4 => { + let r = pr.f("strokeWidth") * sc; + let out = morph_frac(®ion, r * 0.5, true); + let inn = morph_frac(®ion, r * 0.5, false); + out.zip_map(&inn, |a, b| (a - b).max(0.0)) + } + _ => gauss_plane(®ion, 0.5, 0.5), + }; + if pr.e("stroke") == 0 { + // Antialias: keep the hard interior, soften only the boundary. + cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); + } + let color = pr.color("color"); + let op = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let k = (c * op * color[3]).clamp(0.0, 1.0); + if k <= 0.0 { + if mode == 9 { + *px = [0.0; 4]; + } + return; + } + let (dc, da) = unpremul(*px); + let fc = [color[0], color[1], color[2]]; + let mixed = match mode { + 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], + 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], + 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], + 4..=7 => { + let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); + let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); + let (r, g, bl) = match mode { + 4 => hsl_to_rgb(fh, ds, dl), + 5 => hsl_to_rgb(dh, fs, dl), + 6 => hsl_to_rgb(fh, fs, dl), + _ => hsl_to_rgb(dh, ds, fl), + }; + [r, g, bl] + } + _ => fc, + }; + *px = match mode { + 8 => over(premul(fc, k), *px), + 9 => premul(fc, k), + 0 => over(*px, premul(fc, k)), + _ => { + if da <= 1e-6 { + return; + } + let c3 = crate::util::lerp3(dc, mixed, k); + premul(c3, da) + } + }; + }); + b +} + +// ---------------------------------------------------------------- Eyedropper Fill + +fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as i64, b.img.height as i64); + let c = b.to_px(pr.v2("samplePoint")); + let r = (pr.f("sampleRadius") * b.scale).max(0.0); + let mode = pr.e("averagePixelColors"); + let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); + let ri = r.ceil() as i64; + let mut acc = [0.0f64; 4]; + let mut n = 0.0f64; + for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { + for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { + if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { + continue; + } + let px = b.img.data[(y * w + x) as usize]; + match mode { + 0 => { + if px[3] > 1e-4 { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + } + 1 => { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + 2 => { + for k in 0..3 { + acc[k] += px[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + _ => { + for k in 0..4 { + acc[k] += px[k] as f64; + } + n += 1.0; + } + } + } + } + let fill: Px = if n > 0.0 { + if mode == 3 { + let a = (acc[3] / n) as f32; + let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; + [s[0], s[1], s[2], a] + } else { + [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] + } + } else { + [0.0, 0.0, 0.0, 0.0] + }; + let keep_alpha = pr.b("maintainOriginalAlpha"); + let blend = (pr.f("blendWithOriginal") / 100.0) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let a = if keep_alpha { px[3] } else { fill[3] }; + let f = premul([fill[0], fill[1], fill[2]], a); + *px = crate::util::lerp4(f, *px, blend); + }); + b +} + +// ---------------------------------------------------------------- CC Threads + +fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let sc = b.scale; + let tw = (pr.f("width") * sc).max(1.0); + let th = (pr.f("height") * sc).max(1.0); + let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; + let ang = pr.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let c = b.to_px(pr.v2("center")); + let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); + let shadow = (pr.f("shadowing") / 100.0) as f32; + let texture = (pr.f("texture") / 100.0) as f32; + let src = b.img.clone(); + let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let u = dx * cs + dy * sn; + let v = -dx * sn + dy * cs; + let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); + let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); + // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the + // band |fu-0.5| < coverage/2. + let in_h = (fv - 0.5).abs() < coverage * 0.5; + let in_v = (fu - 0.5).abs() < coverage * 0.5; + // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. + let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; + let which = match (in_h, in_v) { + (true, true) => Some(h_top), + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }; + let Some(horizontal) = which else { + *px = [0.0; 4]; + continue; + }; + // Thread colour: the source sampled along the thread centre line. + let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; + let s = src.sample_bilinear_clamped(sx, sy); + // Round profile across the thread + darkening where it dives under its neighbour. + let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; + let along = if horizontal { fu } else { fv }; + let profile = (1.0 - across * across).max(0.0).sqrt() as f32; + let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; + let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; + let fib = + if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; + let k = (shade * fib).max(0.0); + *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; + } + }); + b +} + +// ---------------------------------------------------------------- Audio Spectrum / Waveform + +/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). +pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { + let n = re.len(); + if n < 2 { + return; + } + let mut j = 0; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let ang = -2.0 * PI / len as f64; + let (ws, wc) = ang.sin_cos(); + for start in (0..n).step_by(len) { + let (mut cr, mut ci) = (1.0, 0.0); + for k in 0..len / 2 { + let (a, bb) = (start + k, start + k + len / 2); + let tr = re[bb] * cr - im[bb] * ci; + let ti = re[bb] * ci + im[bb] * cr; + re[bb] = re[a] - tr; + im[bb] = im[a] - ti; + re[a] += tr; + im[a] += ti; + let ncr = cr * wc - ci * ws; + ci = cr * ws + ci * wc; + cr = ncr; + } + } + len <<= 1; + } +} + +const AUDIO_RATE: u32 = 48_000; + +/// Fetch mono (or one channel) samples of the audio layer around the current time. +fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { + let id = ctx.params.get("audioLayer")?.as_layer()?; + let host = ctx.env.host?; + let dur = (dur_ms / 1000.0).max(0.001); + let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); + let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; + let s = host.audio(id, start, frames, AUDIO_RATE)?; + Some( + (0..frames) + .map(|i| { + let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); + match channel { + 1 => l, + 2 => r, + _ => (l + r) * 0.5, + } + }) + .collect(), + ) +} + +/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at +/// parameter u∈[0,1] → (point, unit normal). +struct Baseline { + pts: Vec<[f64; 2]>, + closed: bool, + len: f64, + polar: bool, + center: [f64; 2], + radius: f64, +} + +impl Baseline { + fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { + let pr = ctx.params; + let polar = pr.b("usePolarPath"); + let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { + Some((p, c, _)) if !polar => (p, c), + _ => { + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + (vec![[s.0, s.1], [e.0, e.1]], false) + } + }; + let len = poly_length(&pts, closed); + let center = pts[0]; + let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; + Baseline { pts, closed, len, polar, center, radius } + } + fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { + if self.polar { + let a = u * 2.0 * PI - PI * 0.5; + let (s, c) = a.sin_cos(); + return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); + } + let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); + (q, [d[1], -d[0]]) + } +} + +/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). +fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let core = raster_segs(w, h, segs_core, 1.0); + let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); + let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, hl) = (core.data[i], halo.data[i]); + let a = c.max(hl); + let base = if composite { *px } else { [0.0; 4] }; + if a <= 0.0 { + *px = base; + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); + *px = over(base, premul(col, a)); + }); +} + +fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0) else { return b }; + let n = samples.len().next_power_of_two().max(64); + let mut re = vec![0.0; n]; + let mut im = vec![0.0; n]; + let m = samples.len(); + for (i, s) in samples.iter().enumerate() { + // Hann window. + re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos()); + } + fft(&mut re, &mut im); + let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; + let f0 = pr.f("startFrequency").max(1.0); + let f1 = pr.f("endFrequency").max(f0 + 1.0); + let hz_per_bin = AUDIO_RATE as f64 / n as f64; + let mag = |f: f64| { + let k = ((f / hz_per_bin).round() as usize).min(n / 2); + (re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64 + }; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let side = pr.e("sideOptions"); + let base = Baseline::new(ctx, &b); + let mut tops = Vec::with_capacity(bands); + let mut segs = Vec::new(); + for i in 0..bands { + let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; + let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64); + let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64); + let mut v = 0.0f64; + let k = 4; + for j in 0..k { + v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64)); + } + let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0; + let hgt = db.clamp(0.0, 1.0) * max_h; + let (q, nrm) = base.at(u); + let (ua, ub) = match side { + 0 => (0.0, hgt), + 1 => (-hgt, 0.0), + _ => (-hgt, hgt), + }; + let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; + let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; + match disp { + 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), + _ => tops.push((pa, pb)), + } + } + if disp == 1 { + for w2 in tops.windows(2) { + segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); + } + } + } else if disp == 2 { + for &(pa, pb) in &tops { + segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); + } + } + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions")) else { return b }; + let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let base = Baseline::new(ctx, &b); + let m = samples.len(); + let mut pts = Vec::with_capacity(count); + let mut segs = Vec::new(); + for i in 0..count { + let u = i as f64 / (count - 1) as f64; + let a = ((u * (m - 1) as f64) as usize).min(m - 1); + let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); + // Peak with sign of the window for a stable digital display. + let (mut lo, mut hi) = (0.0f64, 0.0f64); + for s in &samples[a..=bnd] { + lo = lo.min(*s); + hi = hi.max(*s); + } + let (q, nrm) = base.at(u); + match disp { + 0 => segs.push(Seg { + a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], + b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], + r: thick * 0.5, + v: 1.0, + }), + _ => { + let v = samples[a] * max_h; + pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); + } + } + } + if disp == 1 { + poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); + } else if disp == 2 { + segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); + let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), slider(0.0, 32.0, 0.0, 8.0, 0)); + let display = || popup(&["Digital", "Analog lines", "Analog dots"]); + vec![ + spec( + "ec.generate.fractal", + "Fractal", + vec![ + p( + "setChoice", + "Set Choice", + Value::Enum(0), + popup(&["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Inverse", "Mandelbrot Inverse Over Mandelbrot", "Julia", "Julia Inverse"]), + ), + p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), + p("mandelbrotX", "Mandelbrot: X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotY", "Mandelbrot: Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotMagnification", "Mandelbrot: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("mandelbrotEscapeLimit", "Mandelbrot: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("juliaX", "Julia: X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaY", "Julia: Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaMagnification", "Julia: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("juliaEscapeLimit", "Julia: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("postInversionX", "Post-Inversion Offset: X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("postInversionY", "Post-Inversion Offset: Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + check("overlay", "Overlay", false), + check("transparency", "Transparency", false), + p("palette", "Palette", Value::Enum(0), popup(&["Lightness Bands", "Hue Bands", "Hue/Lightness Bands"])), + p("hue", "Hue", num(0.0), ang()), + p("cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), + p("cycleOffset", "Cycle Offset", num(0.0), ang()), + check("edgeHighlight", "Edge Highlight", false), + p("samplingMethod", "Sampling Method", Value::Enum(0), popup(&["Edge Detect", "Every Pixel"])), + p("samplingFactor", "Sampling Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), + ], + fractal, + ), + spec( + "ec.generate.scribble", + "Scribble", + vec![ + p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), + mask_idx("mask", "Mask", 1.0), + p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), + p("edgeWidth", "Edge Options: Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("angle", "Angle", num(45.0), ang()), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), + p("curviness", "Stroke Options: Curviness", num(50.0), pct()), + p("curvinessVariation", "Stroke Options: Curviness Variation", num(50.0), pct()), + p("spacing", "Stroke Options: Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), + p("spacingVariation", "Stroke Options: Spacing Variation", num(50.0), pct()), + p("pathOverlap", "Stroke Options: Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pathOverlapVariation", "Stroke Options: Path Overlap Variation", num(50.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + check("fillPathsSequentially", "Fill Paths Sequentially", true), + p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), + p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), + ], + scribble, + ), + spec( + "ec.generate.stroke", + "Stroke", + vec![ + mask_idx("path", "Path", 1.0), + check("allMasks", "All Masks", false), + check("strokeSequentially", "Stroke Sequentially", true), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("opacity", "Opacity", num(100.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + stroke, + ), + spec( + "ec.generate.vegas", + "Vegas", + vec![ + p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), + p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), + check("invertInput", "Image Contours: Invert Input", false), + p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), + p("threshold", "Image Contours: Threshold", num(50.0), pct()), + p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), + p("renderMode", "Image Contours: Render Mode", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), + p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), + mask_idx("path", "Mask/Path: Path", 1.0), + p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), + p("rotation", "Segments: Rotation", num(0.0), ang()), + check("randomPhase", "Segments: Random Phase", false), + p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), + p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + ], + vegas, + ), + spec( + "ec.generate.writeon", + "Write-on", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("brushOpacity", "Brush Opacity", num(100.0), pct()), + p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), + p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), + p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), + p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + write_on, + ), + spec( + "ec.generate.paintbucket", + "Paint Bucket", + vec![ + p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), + p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), + p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), + check("viewThreshold", "View Threshold", false), + p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), + p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p( + "blendingMode", + "Blending Mode", + Value::Enum(0), + popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), + ), + ], + paint_bucket, + ), + spec( + "ec.generate.eyedropperfill", + "Eyedropper Fill", + vec![ + p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), + p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), + check("maintainOriginalAlpha", "Maintain Original Alpha", false), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + eyedropper_fill, + ), + spec( + "ec.generate.ccgluegun", + "CC Glue Gun", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), + p("reflection", "Reflection", num(45.0), ang()), + p("strength", "Strength", num(100.0), pct()), + p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), + ], + glue_gun, + ), + spec( + "ec.generate.ccthreads", + "CC Threads", + vec![ + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), + p("direction", "Direction", num(0.0), ang()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("coverage", "Coverage", num(80.0), pct()), + p("shadowing", "Shadowing", num(50.0), pct()), + p("texture", "Texture", num(10.0), pct()), + ], + threads, + ), + spec( + "ec.generate.audiospectrum", + "Audio Spectrum", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), + p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), + p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), + p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(50.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + check("blendOverlappingColors", "Blend Overlapping Colors", true), + p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), + check("dynamicHuePhase", "Dynamic Hue Phase", false), + check("colorSymmetry", "Color Symmetry", false), + p("displayOptions", "Display Options", Value::Enum(0), display()), + p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), + check("durationAveraging", "Duration Averaging", false), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_spectrum, + ), + spec( + "ec.generate.audiowaveform", + "Audio Waveform", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), + p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(10.0), pct()), + p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), + p("displayOptions", "Display Options", Value::Enum(1), display()), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_waveform, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; + + /// Run with point defaults scaled to the layer size. + pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + run_env(id, set, img, 0.5, EffectEnv::default()) + } + + fn square_mask() -> Vec { + vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] + } + + fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.data[y * img.width as usize + x][3]; + } + } + s + } + + #[test] + fn fractal_deterministic_and_has_inside_set() { + let img = Image::new(64, 48); + let a = run("ec.generate.fractal", &[], img.clone()); + let b = run("ec.generate.fractal", &[], img.clone()); + assert_eq!(a.img.data, b.img.data); + // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. + assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); + assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); + let t = run("ec.generate.fractal", &[("transparency", Value::Bool(true))], img); + assert!(t.img.data.iter().any(|p| p[3] == 0.0)); + } + + #[test] + fn scribble_fills_mask_and_animates() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); + let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + assert_eq!(a.img.data, b.img.data); + let inside = alpha_sum(&a.img, 18, 18, 46, 46); + let outside = alpha_sum(&a.img, 0, 0, 10, 64); + assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); + let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); + assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); + let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); + let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); + assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); + let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); + assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); + } + + #[test] + fn scribble_edge_types() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeWidth", num(6.0))], img, 0.5, env); + // Outside edge: paint near the border outside, nothing in the centre. + assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); + assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); + } + + #[test] + fn stroke_draws_along_mask() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); + assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); + assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); + let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); + assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); + let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); + assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); + // No masks: unchanged. + let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); + assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); + } + + #[test] + fn vegas_traces_contours() { + let mut img = Image::new(64, 64); + for y in 16..48 { + for x in 16..48 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let a = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + let b = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + assert_eq!(a.img.data, b.img.data); + assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); + assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); + let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); + assert_ne!(r.img.data, a.img.data); + } + + #[test] + fn contours_of_square_are_closed() { + let mut pl = Plane::new(20, 20); + for y in 5..15 { + for x in 5..15 { + pl.data[y * 20 + x] = 1.0; + } + } + let c = contours(&pl, 0.5); + assert_eq!(c.len(), 1); + assert!(c[0].1); + let l = poly_length(&c[0].0, true); + assert!((l - 40.0).abs() < 4.0, "{l}"); + } + + #[test] + fn write_on_and_glue_gun_draw_at_brush() { + let img = Image::new(32, 32); + let a = run("ec.generate.writeon", &[], img.clone()); + assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); + assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); + let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); + let g = run("ec.generate.ccgluegun", &[], src.clone()); + assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); + assert_ne!(g.img.get(16, 16), src.get(16, 16)); + assert_eq!(g.img.get(0, 0), src.get(0, 0)); + } + + #[test] + fn paint_bucket_fills_enclosed_region_only() { + let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); + for i in 8..24 { + for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { + img.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + let out = run("ec.generate.paintbucket", &[], img.clone()); + let inside = out.img.get(16, 16); + assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); + assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); + assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); + assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); + } + + #[test] + fn eyedropper_fill_uses_sample() { + let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); + img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.generate.eyedropperfill", &[], img.clone()); + assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); + let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); + assert_eq!(blended.img.data, img.data); + } + + #[test] + fn threads_weave_is_deterministic_and_gappy() { + let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); + let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); + assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); + assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); + assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); + } + + struct Tone; + impl EffectHost for Tone { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { + Some( + (0..frames) + .flat_map(|i| { + let t = start + i as f64 / rate as f64; + let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; + [v, v] + }) + .collect(), + ) + } + } + + #[test] + fn audio_effects_without_audio_pass_through() { + let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); + } + } + + #[test] + fn audio_effects_draw_with_host() { + let host = Tone; + let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; + let img = Image::new(96, 64); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + assert_eq!(a.img.data, b.img.data, "{id}"); + assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); + } + } + + #[test] + fn fft_finds_tone() { + let n = 256; + let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); + let mut im = vec![0.0; n]; + fft(&mut re, &mut im); + let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); + let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + assert_eq!(peak, 8); + } } From 8bc6d5ba4f61044dc5b219b6e432c40128604384 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:09:16 -0400 Subject: [PATCH 28/32] M9.3: Text effects (Numbers, Timecode) + obsolete Basic Text, Path Text, Lightning Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/textfx.rs | 980 ++++++++++++++++++++++++++++++++++- 1 file changed, 977 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ad66f9573..4a4a8d35c 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -1,7 +1,981 @@ -//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font. +//! Text effects (Numbers, Timecode) and the obsolete generators Basic Text, Path Text and +//! Lightning. +//! +//! Effects sit below the text engine in the crate layering, so text here is drawn with a small +//! built-in single-stroke font (original work: capitals, digits and common punctuation laid out +//! on a 4 × 6 unit grid; lowercase letters use the capitals) and rasterised as anti-aliased round +//! strokes. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate3::{Seg, mask_px, poly_segs, raster_segs}; +use crate::util::{Plane, gauss_plane, hash1, poly_length, poly_point_at}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category, params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- stroke font + +/// Glyph strokes on a grid x ∈ [0, 4], y ∈ [0, 6] (y down, baseline at 6): polylines separated +/// by `;`, points by spaces. A single point is a dot. +fn glyph_src(c: char) -> &'static str { + match c.to_ascii_uppercase() { + '0' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 3.6,0.6 0.4,5.4", + '1' => "1,1 2,0 2,6; 1,6 3,6", + '2' => "0,1 1,0 3,0 4,1 4,2 0,6 4,6", + '3' => "0,0 4,0 2,2.5 3,2.5 4,3.5 4,5 3,6 1,6 0,5", + '4' => "3,6 3,0 0,4 4,4", + '5' => "4,0 0,0 0,2.5 3,2.5 4,3.5 4,5 3,6 0,6", + '6' => "3.5,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 3,2.5 0,2.5", + '7' => "0,0 4,0 1.5,6", + '8' => "1,0 3,0 4,1 4,2 3,3 1,3 0,2 0,1 1,0; 1,3 0,4 0,5 1,6 3,6 4,5 4,4 3,3", + '9' => "4,3.5 1,3.5 0,2.5 0,1 1,0 3,0 4,1 4,5 3,6 0.5,6", + 'A' => "0,6 2,0 4,6; 0.7,4 3.3,4", + 'B' => "0,0 0,6 3,6 4,5 4,4 3,3 0,3; 0,0 3,0 4,1 4,2 3,3", + 'C' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5", + 'D' => "0,0 0,6 2.5,6 4,4.5 4,1.5 2.5,0 0,0", + 'E' => "4,0 0,0 0,6 4,6; 0,3 3,3", + 'F' => "4,0 0,0 0,6; 0,3 3,3", + 'G' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 2.5,3.5", + 'H' => "0,0 0,6; 4,0 4,6; 0,3 4,3", + 'I' => "1,0 3,0; 2,0 2,6; 1,6 3,6", + 'J' => "4,0 4,5 3,6 1,6 0,5", + 'K' => "0,0 0,6; 4,0 0,4; 1.3,3 4,6", + 'L' => "0,0 0,6 4,6", + 'M' => "0,6 0,0 2,3 4,0 4,6", + 'N' => "0,6 0,0 4,6 4,0", + 'O' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0", + 'P' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3", + 'Q' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 2.5,4.5 4,6.3", + 'R' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3; 2,3 4,6", + 'S' => "4,1 3,0 1,0 0,1 0,2 1,3 3,3 4,4 4,5 3,6 1,6 0,5", + 'T' => "0,0 4,0; 2,0 2,6", + 'U' => "0,0 0,5 1,6 3,6 4,5 4,0", + 'V' => "0,0 2,6 4,0", + 'W' => "0,0 1,6 2,2 3,6 4,0", + 'X' => "0,0 4,6; 4,0 0,6", + 'Y' => "0,0 2,3 4,0; 2,3 2,6", + 'Z' => "0,0 4,0 0,6 4,6", + ':' => "2,2; 2,5", + ';' => "2,2; 2,5 1.5,6.8", + '.' => "2,6", + ',' => "2,5.6 1.5,6.8", + '-' => "1,3.5 3,3.5", + '+' => "0.5,3.5 3.5,3.5; 2,2 2,5", + '/' => "4,0 0,6", + '(' => "2.5,-0.3 1.5,1.2 1.5,4.8 2.5,6.3", + ')' => "1.5,-0.3 2.5,1.2 2.5,4.8 1.5,6.3", + '\'' => "2,0 2,1.5", + '"' => "1.4,0 1.4,1.5; 2.6,0 2.6,1.5", + '!' => "2,0 2,4.2; 2,6", + '?' => "0,1 1,0 3,0 4,1 4,2 2,3.5 2,4.4; 2,6", + '%' => "0,6 4,0; 0.6,0.6; 3.4,5.4", + '=' => "0.5,2.5 3.5,2.5; 0.5,4.5 3.5,4.5", + '#' => "1.3,0.5 1,5.5; 3,0.5 2.7,5.5; 0,2 4,2; 0,4 4,4", + '&' => "4,6 1,2 1,0.8 1.8,0 2.8,0.8 2.8,1.8 0,4 0,5.2 1,6 2.5,6 4,4", + '*' => "2,1 2,4; 0.7,1.8 3.3,3.2; 3.3,1.8 0.7,3.2", + '_' => "0,6.5 4,6.5", + '<' => "4,1 0,3.5 4,6", + '>' => "0,1 4,3.5 0,6", + '@' => "3,4 3,2 1.8,2 1.2,3 1.8,4 3,4 4,3.5 4,1 3,0 1,0 0,1 0,5 1,6 3.5,6", + '$' => "4,1 3,0.5 1,0.5 0,1.5 1,3 3,3 4,4.5 3,5.5 1,5.5 0,5; 2,-0.3 2,6.3", + _ => "", + } +} + +fn parse_glyph(c: char) -> Vec> { + glyph_src(c) + .split(';') + .map(|s| { + s.split_whitespace() + .filter_map(|pt| { + let (x, y) = pt.split_once(',')?; + Some([x.parse().ok()?, y.parse().ok()?]) + }) + .collect::>() + }) + .filter(|v| !v.is_empty()) + .collect() +} + +/// Advance width in grid units (glyph ink + 1.6 units of side bearing). +fn advance(c: char, proportional: bool) -> f64 { + if c == ' ' { + return 3.0; + } + if !proportional { + return 5.6; + } + let g = parse_glyph(c); + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for s in &g { + for q in s { + lo = lo.min(q[0]); + hi = hi.max(q[0]); + } + } + if lo > hi { 3.0 } else { (hi - lo) + 1.6 } +} + +/// Left ink edge of a glyph (so proportional glyphs start at their ink). +fn ink_left(c: char, proportional: bool) -> f64 { + if !proportional { + return 0.8; + } + parse_glyph(c).iter().flatten().map(|q| q[0]).fold(f64::INFINITY, f64::min).min(4.0) - 0.8 +} + +/// Text metrics: grid unit in px for a font size (cap height ≈ 0.7 em) and stroke radius. +fn unit(size: f64) -> f64 { + size * 0.7 / 6.0 +} +fn weight(size: f64) -> f64 { + (size * 0.045).max(0.35) +} + +/// Width of a line of text in px (tracking in px between characters). +fn line_width(text: &str, size: f64, tracking: f64, proportional: bool) -> f64 { + let u = unit(size); + let n = text.chars().count(); + if n == 0 { + return 0.0; + } + text.chars().map(|c| advance(c, proportional) * u).sum::() + tracking * (n as f64 - 1.0) - 1.6 * u +} + +/// Polylines (px) of a line of text whose baseline starts at `origin`. +fn layout_line(text: &str, origin: [f64; 2], size: f64, tracking: f64, proportional: bool, out: &mut Vec>) { + let u = unit(size); + let mut x = origin[0]; + for c in text.chars() { + let left = ink_left(c, proportional); + for s in parse_glyph(c) { + out.push(s.iter().map(|q| [x + (q[0] - left - 0.8) * u, origin[1] + (q[1] - 6.0) * u]).collect()); + } + x += advance(c, proportional) * u + tracking; + } +} + +/// How text paints: fill/stroke display option, colours, and compositing. +struct TextLook { + display: u32, + fill: [f32; 4], + stroke: [f32; 4], + stroke_w: f64, + on_original: bool, + opacity: f32, +} + +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +fn tint(c: [f32; 4], a: f32) -> Px { + let a = (a * c[3]).clamp(0.0, 1.0); + [c[0] * a, c[1] * a, c[2] * a, a] +} + +/// Draw text polylines with the given look (`r` = glyph stroke radius in px). +fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let segs_at = |rad: f64| { + let mut s = Vec::new(); + for pl in polys { + poly_segs(pl, false, rad.max(0.05), 1.0, &mut s); + } + s + }; + let fill = raster_segs(w, h, &segs_at(r), 1.0); + let sw = look.stroke_w.max(0.0); + let ring = if look.display == 0 || sw <= 0.0 { + None + } else { + let outer = raster_segs(w, h, &segs_at(r + sw * 0.5), 1.0); + let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) }; + Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0))) + }; + let op = look.opacity; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut out = if look.on_original { *px } else { [0.0; 4] }; + let f = fill.data[i] * op; + let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op; + match look.display { + 1 => out = over(out, tint(look.stroke, s)), + 2 => { + out = over(out, tint(look.stroke, s)); + out = over(out, tint(look.fill, f)); + } + 3 => { + out = over(out, tint(look.fill, f)); + out = over(out, tint(look.stroke, s)); + } + _ => out = over(out, tint(look.fill, f)), + } + *px = out; + }); +} + +fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { + let pr = ctx.params; + TextLook { + display: pr.e("displayOptions"), + fill: pr.color("fillColor"), + stroke: pr.color("strokeColor"), + stroke_w: pr.f("strokeWidth") * b.scale, + on_original: pr.b(on_original_id), + opacity: 1.0, + } +} + +// ---------------------------------------------------------------- Numbers + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + +/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +fn date_from_days(days: i64) -> (i64, u32, u32, usize) { + // Days since 0000-03-01 based civil calendar conversion. + let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d, days.rem_euclid(7) as usize) +} + +fn timecode(frames: i64, fps: f64) -> String { + let fr = fps.round().max(1.0) as i64; + let neg = frames < 0; + let f = frames.abs(); + let s = format!("{:02}:{:02}:{:02}:{:02}", f / (fr * 3600), (f / (fr * 60)) % 60, (f / fr) % 60, f % fr); + if neg { format!("-{s}") } else { s } +} + +fn numbers_text(ctx: &EffectCtx) -> String { + let pr = ctx.params; + let kind = pr.e("type"); + let dp = pr.f("decimalPlaces").round().clamp(0.0, 10.0) as usize; + let mut v = pr.f("value"); + if pr.b("randomValues") { + let frame = (ctx.time * ctx.fps()).floor() as i64 as u32; + v = hash1(frame, 17, ctx.seed) as f64 * pr.f("value"); + } + let cur = pr.b("currentTimeDate"); + let t = if cur { ctx.time + v } else { v }; + match kind { + 1 => { + let s = format!("{:.*}", dp, v.abs()); + let (ip, fp) = s.split_once('.').map(|(a, b)| (a.to_string(), format!(".{b}"))).unwrap_or((s.clone(), String::new())); + format!("{}{:0>5}{}", if v < 0.0 { "-" } else { "" }, ip, fp) + } + 2..=4 => { + let fps = [30.0, 25.0, 24.0][(kind - 2) as usize]; + timecode((t * fps + 1e-6).floor() as i64, fps) + } + 5 => { + let s = t.floor() as i64; + let a = s.abs(); + format!("{}{:02}:{:02}:{:02}", if s < 0 { "-" } else { "" }, a / 3600, (a / 60) % 60, a % 60) + } + 6..=8 => { + let (y, m, d, wd) = date_from_days(v.floor() as i64); + match kind { + 6 => format!("{:02}/{:02}/{:02}", m, d, y.rem_euclid(100)), + 7 => format!("{} {}, {}", &MONTHS[m as usize - 1][..3], d, y), + _ => format!("{}, {} {}, {}", DAYS[wd], MONTHS[m as usize - 1], d, y), + } + } + 9 => { + let i = v.round() as i64; + if i < 0 { format!("-{:X}", -i) } else { format!("{i:X}") } + } + _ => format!("{:.*}", dp, if cur { t } else { v }), + } +} + +fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let text = numbers_text(ctx); + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let prop = pr.b("proportionalSpacing"); + let pos = b.to_px(pr.v2("position")); + let wdt = line_width(&text, size, tracking, prop); + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys); + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Timecode + +/// SMPTE drop-frame label for nominal 30/60 fps rates. +fn drop_frame(frames: i64, nominal: i64) -> String { + let drop = nominal / 15; // 2 at 30, 4 at 60 + let per_min = nominal * 60 - drop; + let per_10 = per_min * 10 + drop; + let f = frames.max(0); + let d = f / per_10; + let m = f % per_10; + let adj = if m > drop { f + drop * 9 * d + drop * ((m - drop) / per_min) } else { f + drop * 9 * d }; + format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal) +} + +fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let src = pr.e("timeSource"); + let (t, fps) = match src { + 1 => (ctx.env.comp_time, ctx.fps()), + 2 => (ctx.time, pr.f("timeUnits").max(1.0)), + _ => (ctx.time, ctx.fps()), + }; + let mut frame = (t * fps + 1e-6).floor() as i64; + if src == 2 { + frame += pr.f("startingFrame").round() as i64; + } + let nominal = fps.round() as i64; + let text = match pr.e("displayFormat") { + 1 => format!("{frame}"), + 2 => format!("{}+{:02}", frame.div_euclid(16), frame.rem_euclid(16)), + 3 => format!("{}+{:02}", frame.div_euclid(40), frame.rem_euclid(40)), + _ => { + if pr.b("dropFrame") && (nominal == 30 || nominal == 60) { + drop_frame(frame, nominal) + } else { + timecode(frame, fps) + } + } + }; + let size = (pr.f("textSize") * b.scale).max(0.5); + let pos = b.to_px(pr.v2("textPosition")); + let wdt = line_width(&text, size, 0.0, false); + let op = (pr.f("opacity") / 100.0) as f32; + if !pr.b("renderOnOriginal") { + b.img = Image::new(b.img.width, b.img.height); + } + if pr.b("showBox") { + let bc = pr.color("boxColor"); + let pad = size * 0.25; + let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad); + let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5); + let a = op * bc[3]; + b.img.rows_mut().for_each(|(y, row)| { + let fy = y as f64 + 0.5; + let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32; + if cy <= 0.0 { + return; + } + for (x, px) in row.iter_mut().enumerate() { + let fx = x as f64 + 0.5; + let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32; + let k = cx * cy * a; + if k > 0.0 { + *px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]); + } + } + }); + } + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys); + let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op }; + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Basic Text + +fn basic_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let lead = size * pr.f("lineSpacing") / 100.0 * 1.2; + let pos = b.to_px(pr.v2("position")); + let align = pr.e("alignment"); + let lines: Vec<&str> = pr.s("text").split(['\n', '\r']).collect(); + let n = lines.len() as f64; + let mut polys = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let wdt = line_width(l, size, tracking, true); + let x = match align { + 0 => pos.0, + 2 => pos.0 - wdt, + _ => pos.0 - wdt * 0.5, + }; + let y = pos.1 + unit(size) * 3.0 + (i as f64 - (n - 1.0) * 0.5) * lead; + layout_line(l, [x, y], size, tracking, true, &mut polys); + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Path Text + +fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let px = |id: &str| { + let q = b.to_px(pr.v2(id)); + [q.0, q.1] + }; + let (mut path, mut closed) = match mask_px(ctx, &b, pr.f("customPath").round() as usize) { + Some((p, c, _)) => (p, c), + None => match pr.e("shapeType") { + 1 | 2 => { + let c = px("vertex1"); + let s = px("tangent1"); + let r = ((s[0] - c[0]).powi(2) + (s[1] - c[1]).powi(2)).sqrt().max(1.0); + let a0 = (s[1] - c[1]).atan2(s[0] - c[0]); + let n = 128; + ( + (0..n) + .map(|i| { + let a = a0 + 2.0 * PI * i as f64 / n as f64; + [c[0] + a.cos() * r, c[1] + a.sin() * r] + }) + .collect(), + true, + ) + } + 3 => (vec![px("vertex1"), px("vertex2")], false), + _ => { + let (v1, t1, t2, v2) = (px("vertex1"), px("tangent1"), px("tangent2"), px("vertex2")); + ( + (0..=64) + .map(|i| { + let t = i as f64 / 64.0; + let u = 1.0 - t; + let f = |k: usize| u * u * u * v1[k] + 3.0 * u * u * t * t1[k] + 3.0 * u * t * t * t2[k] + t * t * t * v2[k]; + [f(0), f(1)] + }) + .collect(), + false, + ) + } + }, + }; + if pr.b("reversePath") { + path.reverse(); + } + if path.len() < 2 { + closed = false; + } + let len = poly_length(&path, closed); + let text: String = pr.s("text").chars().take(pr.f("visibleCharacters").max(0.0) as usize).collect(); + let size = (pr.f("size") * b.scale).max(0.5); + let u = unit(size); + let tracking = pr.f("tracking") * b.scale; + let lm = pr.f("leftMargin") * b.scale; + let rm = pr.f("rightMargin") * b.scale; + let shift = pr.f("baselineShift") * b.scale; + let rot = pr.f("characterRotation").to_radians(); + let n = text.chars().count(); + let advs: Vec = text.chars().map(|c| advance(c, true) * u).collect(); + let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0); + let avail = (len - lm - rm).max(0.0); + let (mut cursor, extra) = match pr.e("alignment") { + 1 => (len - rm - natural, 0.0), + 2 => (lm + (avail - natural) * 0.5, 0.0), + 3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }), + _ => (lm, 0.0), + }; + let mut polys = Vec::new(); + for (c, adv) in text.chars().zip(advs) { + let mid = cursor + adv * 0.5; + cursor += adv + tracking + extra; + if !closed && (mid < 0.0 || mid > len) { + continue; + } + let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid }); + let ang = d[1].atan2(d[0]) + rot; + let (s, co) = ang.sin_cos(); + let left = ink_left(c, true); + for st in parse_glyph(c) { + polys.push( + st.iter() + .map(|g| { + let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u; + let ly = (g[1] - 6.0) * u - shift; + [q[0] + lx * co - ly * s, q[1] + lx * s + ly * co] + }) + .collect(), + ); + } + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Lightning (obsolete) + +/// Smooth 1-D value noise in [-1, 1]. +fn vnoise(i: u32, t: f64, seed: u32) -> f64 { + let k = t.floor(); + let f = t - k; + let a = hash1(i, k as i64 as u32, seed) as f64 * 2.0 - 1.0; + let c = hash1(i, (k as i64 + 1) as u32, seed) as f64 * 2.0 - 1.0; + let s = f * f * (3.0 - 2.0 * f); + a + (c - a) * s +} + +struct Bolt<'a> { + seed: u32, + phase: f64, + stability: f64, + detail: u32, + detail_amp: f64, + pull: [f64; 2], + out: &'a mut Vec, + counter: u32, +} + +impl Bolt<'_> { + /// One bolt from `a` to `z` with `n` segments of amplitude `amp`; returns its points. + fn path(&mut self, a: [f64; 2], z: [f64; 2], n: usize, amp: f64, fixed_end: bool) -> Vec<[f64; 2]> { + let (dx, dy) = (z[0] - a[0], z[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let nrm = [-dy / len, dx / len]; + let id = self.counter; + self.counter += 1; + let mut pts = Vec::with_capacity(n + 1); + for i in 0..=n { + let t = i as f64 / n as f64; + let env = if fixed_end { (t * PI).sin() } else { t.sqrt() }; + let off = vnoise(id * 1000 + i as u32, self.phase * (1.0 - self.stability * 0.9) + i as f64 * 0.37, self.seed) * amp * env; + let pull = t * t * len * 0.25; + pts.push([a[0] + dx * t + nrm[0] * off + self.pull[0] * pull, a[1] + dy * t + nrm[1] * off + self.pull[1] * pull]); + } + // Detail: midpoint displacement. + for lvl in 0..self.detail { + let mut nx = Vec::with_capacity(pts.len() * 2); + for (j, w) in pts.windows(2).enumerate() { + let (p0, p1) = (w[0], w[1]); + let (ex, ey) = (p1[0] - p0[0], p1[1] - p0[1]); + let l = (ex * ex + ey * ey).sqrt(); + let o = vnoise(id * 7919 + lvl * 131 + j as u32, self.phase * 1.7 + j as f64 * 0.61, self.seed ^ 0x55) * l * self.detail_amp; + nx.push(p0); + nx.push([(p0[0] + p1[0]) * 0.5 - ey / l.max(1e-9) * o, (p0[1] + p1[1]) * 0.5 + ex / l.max(1e-9) * o]); + } + nx.push(*pts.last().unwrap_or(&a)); + pts = nx; + } + pts + } +} + +fn lightning(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + let sc = b.scale; + let rerun = pr.b("rerunAtEachFrame"); + let speed = pr.f("speed"); + let frame = (ctx.time * ctx.fps()).floor(); + let seed = (pr.f("randomSeed") as i64 as u32) ^ ctx.seed ^ if rerun { (frame as i64 as u32).wrapping_mul(2_654_435_761) } else { 0 }; + let phase = if rerun { 0.0 } else { ctx.time * speed * 0.25 }; + let pd = (pr.f("pullDirection") - 90.0).to_radians(); + let pf = pr.f("pullForce") / 100.0; + let width = (pr.f("width") * sc).max(0.2); + let wvar = pr.f("widthVariation").clamp(0.0, 1.0); + let core = pr.f("coreWidth").clamp(0.0, 1.0); + let mut segs: Vec = Vec::new(); + let mut bolt = Bolt { + seed, + phase, + stability: pr.f("stability").clamp(0.0, 1.0), + detail: pr.f("detailLevel").round().clamp(0.0, 6.0) as u32, + detail_amp: pr.f("detailAmplitude").clamp(0.0, 1.0) * 0.5, + pull: [pd.cos() * pf, pd.sin() * pf], + out: &mut segs, + counter: 0, + }; + let nseg = pr.f("segments").round().clamp(1.0, 64.0) as usize; + let amp = pr.f("amplitude") * sc; + let main = bolt.path([s.0, s.1], [e.0, e.1], nseg, amp, pr.b("fixedEndpoint")); + let branching = pr.f("branching").clamp(0.0, 1.0); + let rebranch = pr.f("rebranching").clamp(0.0, 1.0); + let bang = pr.f("branchAngle").to_radians(); + let bseg_len = pr.f("branchSegLength").max(0.0); + let bsegs = pr.f("branchSegments").round().clamp(1.0, 32.0) as usize; + let bwidth = pr.f("branchWidth").clamp(0.0, 1.0); + let main_len = poly_length(&main, false); + let seg_len = main_len / nseg as f64; + let mut lines: Vec<(Vec<[f64; 2]>, f64)> = vec![(main.clone(), 1.0)]; + let mut queue: Vec<(Vec<[f64; 2]>, f64, u32)> = vec![(main, 1.0, 0)]; + while let Some((pts, w, depth)) = queue.pop() { + if depth >= 3 || lines.len() > 256 { + continue; + } + let prob = if depth == 0 { branching } else { rebranch }; + for (i, wpair) in pts.windows(2).enumerate() { + if hash1(i as u32 + depth * 977, lines.len() as u32, seed ^ 0xB7) as f64 >= prob * 0.35 { + continue; + } + let (p0, p1) = (wpair[0], wpair[1]); + let base = (p1[1] - p0[1]).atan2(p1[0] - p0[0]); + let side = if hash1(i as u32, depth + 3, seed) < 0.5 { -1.0 } else { 1.0 }; + let a = base + side * bang * (0.5 + hash1(i as u32, depth + 9, seed) as f64); + let l = seg_len * bseg_len * bsegs as f64; + let z = [p0[0] + a.cos() * l, p0[1] + a.sin() * l]; + let br = bolt.path(p0, z, bsegs, amp * 0.5, false); + let bw = w * bwidth; + lines.push((br.clone(), bw)); + queue.push((br, bw, depth + 1)); + } + } + for (li, (pts, w)) in lines.iter().enumerate() { + for (i, wpair) in pts.windows(2).enumerate() { + let v = 1.0 - wvar * hash1(i as u32, li as u32, seed ^ 0x77) as f64; + bolt.out.push(Seg { a: wpair[0], b: wpair[1], r: width * 0.5 * w * v, v: 1.0 }); + } + } + let (wd, ht) = (b.img.width as usize, b.img.height as usize); + let outer = raster_segs(wd, ht, &segs, 0.0); + let core_segs: Vec = segs.iter().map(|g| Seg { r: (g.r * core).max(0.3), ..*g }).collect(); + let inner = raster_segs(wd, ht, &core_segs, 0.6); + let glow = gauss_plane(&outer, width * 0.5, width * 0.5); + let oc = pr.color("outsideColor"); + let ic = pr.color("insideColor"); + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let o = outer.data[i].max(glow.data[i] * 0.8); + let c = inner.data[i]; + let a = o.max(c).min(1.0); + if a <= 0.0 { + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let colr = [oc[0] + (ic[0] - oc[0]) * t, oc[1] + (ic[1] - oc[1]) * t, oc[2] + (ic[2] - oc[2]) * t]; + *px = match mode { + 1 => [px[0] + colr[0] * a, px[1] + colr[1] * a, px[2] + colr[2] * a, (px[3] + a * (1.0 - px[3])).min(1.0)], + 2 => { + let sc = |d: f32, s: f32| d + s - d * s; + [sc(px[0], colr[0] * a), sc(px[1], colr[1] * a), sc(px[2], colr[2] * a), sc(px[3], a)] + } + _ => over(*px, [colr[0] * a, colr[1] * a, colr[2] * a, a]), + }; + }); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let pt = |x: f64, y: f64| Value::Vec2([x, y]); + let display = || popup(&["Fill Only", "Stroke Only", "Fill Over Stroke", "Stroke Over Fill"]); + let size = || slider(0.0, 512.0, 0.0, 200.0, 1); + vec![ + spec( + "ec.text.numbers", + "Numbers", + "Text", + vec![ + p( + "type", + "Format: Type", + Value::Enum(0), + popup(&[ + "Number", + "Number [Leading Zeros]", + "Timecode [30]", + "Timecode [25]", + "Timecode [24]", + "Time", + "Numerical Date", + "Short Date", + "Long Date", + "Hexadecimal", + ]), + ), + check("randomValues", "Format: Random Values", false), + p("value", "Format: Value/Offset/Random Max", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 3)), + p("decimalPlaces", "Format: Decimal Places", num(2.0), slider(0.0, 10.0, 0.0, 10.0, 0)), + check("currentTimeDate", "Format: Current Time/Date", false), + p("position", "Fill and Stroke: Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + check("proportionalSpacing", "Proportional Spacing", true), + check("compositeOnOriginal", "Composite On Original", true), + ], + numbers, + ), + spec( + "ec.text.timecode", + "Timecode", + "Text", + vec![ + p("displayFormat", "Display Format", Value::Enum(0), popup(&["SMPTE HH:MM:SS:FF", "Frames", "Feet + Frames (35mm)", "Feet + Frames (16mm)"])), + p("timeSource", "Time Source", Value::Enum(0), popup(&["Layer Source", "Composition", "Custom"])), + p("timeUnits", "Time Units", num(30.0), slider(1.0, 120.0, 1.0, 60.0, 2)), + check("dropFrame", "Drop Frame", false), + p("startingFrame", "Starting Frame", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 10_000.0, 0)), + p("textPosition", "Text Position", pt(0.5, 0.9), ParamUi::Point), + p("textSize", "Text Size", num(36.0), size()), + p("textColor", "Text Color", col(1.0, 1.0, 1.0), ParamUi::Color), + check("showBox", "Show Box", true), + p("boxColor", "Box Color", col(0.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + check("renderOnOriginal", "Render On Original", true), + ], + timecode_fx, + ), + spec( + "ec.obsolete.basictext", + "Basic Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("alignment", "Alignment", Value::Enum(1), popup(&["Left", "Center", "Right"])), + p("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("lineSpacing", "Line Spacing", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + check("compositeOnOriginal", "Composite On Original", true), + ], + basic_text, + ), + spec( + "ec.obsolete.pathtext", + "Path Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("shapeType", "Path Options: Shape Type", Value::Enum(0), popup(&["Bezier", "Circle", "Loop", "Line"])), + p("tangent1", "Control Points: Tangent 1/Circle Point", pt(0.3, 0.3), ParamUi::Point), + p("vertex1", "Control Points: Vertex 1/Circle Center", pt(0.1, 0.6), ParamUi::Point), + p("tangent2", "Control Points: Tangent 2", pt(0.7, 0.3), ParamUi::Point), + p("vertex2", "Control Points: Vertex 2/Line Right", pt(0.9, 0.6), ParamUi::Point), + p("customPath", "Path Options: Custom Path", num(0.0), slider(0.0, 32.0, 0.0, 8.0, 0)), + check("reversePath", "Path Options: Reverse Path", false), + p("displayOptions", "Fill and Stroke: Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Character: Size", num(36.0), size()), + p("tracking", "Character: Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("characterRotation", "Character: Orientation: Character Rotation", num(0.0), ang()), + p("alignment", "Paragraph: Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])), + p("leftMargin", "Paragraph: Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("rightMargin", "Paragraph: Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("baselineShift", "Paragraph: Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("visibleCharacters", "Advanced: Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)), + check("compositeOnOriginal", "Composite On Original", true), + ], + path_text, + ), + spec( + "ec.obsolete.lightning", + "Lightning", + "Obsolete", + vec![ + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + p("segments", "Segments", num(7.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("amplitude", "Amplitude", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("detailLevel", "Detail Level", num(2.0), slider(0.0, 6.0, 0.0, 6.0, 0)), + p("detailAmplitude", "Detail Amplitude", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branching", "Branching", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rebranching", "Rebranching", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branchAngle", "Branch Angle", num(20.0), ang()), + p("branchSegLength", "Branch Seg. Length", num(0.5), slider(0.0, 2.0, 0.0, 2.0, 3)), + p("branchSegments", "Branch Segments", num(4.0), slider(1.0, 32.0, 1.0, 16.0, 0)), + p("branchWidth", "Branch Width", num(0.7), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("speed", "Speed", num(10.0), slider(-100.0, 100.0, -50.0, 50.0, 1)), + p("stability", "Stability", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + check("fixedEndpoint", "Fixed Endpoint", true), + p("width", "Width", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("widthVariation", "Width Variation", num(0.25), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("coreWidth", "Core Width", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("outsideColor", "Outside Color", col(0.36, 0.25, 0.9), ParamUi::Color), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pullForce", "Pull Force", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pullDirection", "Pull Direction", num(0.0), ang()), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Add", "Screen"])), + check("rerunAtEachFrame", "Rerun At Each Frame", false), + ], + lightning, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn run(id: &str, set: &[(&str, Value)], img: Image, time: f64) -> Buf { + run_env(id, set, img, time, EffectEnv::default()) + } + + fn ink(img: &Image) -> f32 { + img.data.iter().map(|p| p[3]).sum() + } + + #[test] + fn every_glyph_parses_within_grid() { + for c in (b'!'..=b'~').map(|c| c as char) { + for s in parse_glyph(c) { + for q in s { + assert!((-0.5..=4.5).contains(&q[0]) && (-0.5..=7.0).contains(&q[1]), "{c}: {q:?}"); + } + } + } + assert!(!parse_glyph('A').is_empty() && !parse_glyph('a').is_empty() && parse_glyph(' ').is_empty()); + } + + #[test] + fn numbers_formats() { + let params = |vals: &[(&str, Value)]| { + let s = crate::find("ec.text.numbers").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + p.values.insert(k.to_string(), v.clone()); + } + p + }; + let txt = |vals: &[(&str, Value)], t: f64| { + let pr = params(vals); + let ctx = EffectCtx { params: &pr, time: t, layer_size: [10.0, 10.0], seed: 1, adjustment: false, env: Default::default() }; + numbers_text(&ctx) + }; + assert_eq!(txt(&[("value", num(8.0))], 0.0), "8.00"); + assert_eq!(txt(&[("value", num(42.5)), ("type", Value::Enum(1))], 0.0), "00042.50"); + assert_eq!(txt(&[("value", num(3661.5)), ("type", Value::Enum(2))], 0.0), "01:01:01:15"); + assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); + assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); + assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); + assert_ne!( + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 1.0) + ); + } + + #[test] + fn numbers_draws_glyph_at_position() { + let img = Image::new(80, 60); + let a = run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0); + assert_eq!(a.img.data, run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0).img.data); + // Ink only around the centre. + let mut inside = 0.0; + let mut outside = 0.0; + for y in 0..60 { + for x in 0..80 { + let v = a.img.get(x, y)[3]; + if (25..55).contains(&x) && (10..50).contains(&y) { + inside += v; + } else { + outside += v; + } + } + } + assert!(inside > 20.0 && outside < 0.5, "{inside} {outside}"); + let stroke = run("ec.text.numbers", &[("value", num(8.0)), ("displayOptions", Value::Enum(2))], img, 0.0); + assert!(stroke.img.data.iter().any(|p| p[3] > 0.5 && p[1] > 0.5 * p[3]), "white stroke visible"); + } + + #[test] + fn timecode_changes_over_time() { + let img = Image::filled(120, 60, [0.2, 0.2, 0.2, 1.0]); + let a = run("ec.text.timecode", &[], img.clone(), 0.0); + let b = run("ec.text.timecode", &[], img.clone(), 1.0); + assert_eq!(a.img.data, run("ec.text.timecode", &[], img.clone(), 0.0).img.data); + assert_ne!(a.img.data, b.img.data); + assert_eq!(timecode(30 * 61 + 5, 30.0), "00:01:01:05"); + assert_eq!(drop_frame(1800, 30), "00;01;00;02"); + assert_eq!(drop_frame(17982, 30), "00;10;00;00"); + } + + #[test] + fn basic_text_draws_and_respects_composite() { + let img = Image::filled(100, 50, [0.0, 0.0, 1.0, 1.0]); + let a = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into()))], img.clone(), 0.0); + assert_eq!(a.img.get(1, 1), [0.0, 0.0, 1.0, 1.0]); + assert!(a.img.data.iter().any(|p| p[0] > 0.9)); + let t = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into())), ("compositeOnOriginal", Value::Bool(false))], img, 0.0); + assert_eq!(t.img.get(1, 1)[3], 0.0); + assert!(ink(&t.img) > 10.0); + } + + #[test] + fn path_text_follows_line_and_mask() { + let img = Image::new(120, 60); + let line = run( + "ec.obsolete.pathtext", + &[("shapeType", Value::Enum(3)), ("vertex1", Value::Vec2([5.0, 50.0])), ("vertex2", Value::Vec2([115.0, 50.0])), ("size", num(20.0))], + img.clone(), + 0.0, + ); + assert!(ink(&line.img) > 10.0); + // Text sits above the baseline at y = 50. + let below: f32 = (52..60).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| line.img.get(x, y)[3]).sum(); + assert!(below < 1.0); + let masks = vec![MaskShape { name: "M".into(), points: vec![[10.0, 20.0], [110.0, 20.0]], closed: false, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let m = run_env("ec.obsolete.pathtext", &[("customPath", num(1.0)), ("size", num(20.0))], img.clone(), 0.0, env); + let near: f32 = (0..24).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| m.img.get(x, y)[3]).sum(); + assert!(near > 10.0 && (ink(&m.img) - near).abs() < 1.0); + let circle = run("ec.obsolete.pathtext", &[("shapeType", Value::Enum(1)), ("text", Value::Str("CIRCLE TEXT".into()))], img, 0.0); + assert!(ink(&circle.img) > 10.0); + } + + #[test] + fn lightning_deterministic_and_animates() { + let img = Image::new(96, 64); + let a = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + let b = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.get(48, 32)[3] > 0.0 || ink(&a.img) > 50.0); + let c = run("ec.obsolete.lightning", &[], img.clone(), 1.5); + assert_ne!(a.img.data, c.img.data); + let r1 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img.clone(), 0.5); + let r2 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img, 0.5); + assert_eq!(r1.img.data, r2.img.data); + } } From 0acc10617574d1725591391e8cf4369b0a5b5c18 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:12:52 -0400 Subject: [PATCH 29/32] M6.4: pen tool masks, mask vertex selection/dragging, tangent handles Engine (mask.rs, tests in tests_timeline.rs incl. render + undo/redo): mask.new, mask.addVertex, mask.setVertex, mask.setClosed (Layer > Mask and Shape Path > Closed), mask.selectVertices, mask.moveVertices (merged drags), mask.deleteVertices (Delete with vertices selected), mask.remove / mask.removeAll (Layer > Mask). Edits of an animated Mask Path key the current time. Viewer: Pen tool on the selected layer - click = corner vertex, drag = Bezier vertex, click the first vertex to close, Esc/Enter or another tool ends the path; rubber band to the pointer. Selection tool: click/shift-click mask vertices, drag selected vertices, drag tangent handles (symmetric; Alt breaks). Vertices register automation ids viewer.mask..vertex.. Mode/inverted/feather/opacity/expansion stay in the timeline mask rows. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 8 +- crates/engine/src/commands/layer.rs | 2 + crates/engine/src/commands/mask.rs | 260 ++++++++++++++++++++++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/tests_timeline.rs | 52 ++++++ crates/ui-egui/src/panels/viewer.rs | 244 ++++++++++++++++++++++++-- 6 files changed, 551 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/commands/mask.rs diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 3fc62cf8e..027c23b65 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -39,6 +39,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { s.state.selected_layers.clear(); s.state.selected_props.clear(); s.state.selected_keys.clear(); + s.state.selected_vertices.clear(); Ok(Value::Null) } @@ -72,10 +73,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } fn delete(s: &mut Session, p: &Value) -> Result { - // Keyframes selected → delete keys; else layers. + // Keyframes selected → delete keys; mask vertices → delete them; else layers. if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { return s.execute("keys.delete", json!({})); } + if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { + return s.execute("mask.deleteVertices", json!({})); + } let (cid, ids) = layers_p(s, p)?; s.edit("Clear", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -217,7 +221,7 @@ pub fn specs() -> Vec { } fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { - if !s.state.selected_keys.is_empty() { super::has_comp(s) } else { has_layers(s) } + if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) } } fn always_ok(_: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d6b4593e2..946728b28 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -274,6 +274,8 @@ fn select(s: &mut Session, p: &Value) -> Result { s.state.selected_layers = ids; s.state.selected_props.clear(); s.state.selected_keys.clear(); + let keep = s.state.selected_layers.clone(); + s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); } Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) } diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs new file mode 100644 index 000000000..9387055b3 --- /dev/null +++ b/crates/engine/src/commands/mask.rs @@ -0,0 +1,260 @@ +//! Masks drawn and edited in the viewer: Pen tool (new mask, add vertex, Bezier tangents, close +//! path), vertex selection, moving and deleting vertices, and Layer ▸ Mask ▸ Remove (All). +//! +//! Coordinates are in layer space. Edits of an animated Mask Path set a key at the current time. + +use effectcraft_keyframe::{ShapePath, Value as KV}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Layer, MaskMode, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, has_comp, has_layers, layer_mut, layer_p, merge_p, resolve_layer, str_p}; +use crate::{EngineError, Result, Session, VertexRef, cmd}; + +fn pt(v: Option<&Value>) -> Option<[f64; 2]> { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) +} + +fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { + v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default() +} + +/// Find a mask group by uid, 1-based index or name. +fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { + let i = match key { + Value::Number(n) => { + let n = n.as_u64()?; + masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))? + } + Value::String(s) => masks.children.iter().position(|c| c.name() == s)?, + _ => return None, + }; + masks.children.get_mut(i)?.as_group_mut() +} + +/// Edit the mask path of (layer, mask) at the current time. +fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { + let (cid, lid) = layer_p(s, p, label)?; + let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?; + let t = s.time(); + s.edit(label, merge_p(p), |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let lt = l.layer_time(t); + let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?; + let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?; + let uid = g.uid; + let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; + let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; + let r = f(&mut sp)?; + pr.set_value_at(lt, KV::Path(sp)); + Ok((uid, r)) + }) +} + +/// Pen tool: a new (open by default) mask from points. +fn new_mask(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "mask.new")?; + let v = pts(p.get("vertices")); + if v.is_empty() { + return Err(bad("mask.new", "need `vertices` [[x,y], …] in layer space")); + } + let n = v.len(); + let mut ins = pts(p.get("inTangents")); + let mut outs = pts(p.get("outTangents")); + ins.resize(n, [0.0; 2]); + outs.resize(n, [0.0; 2]); + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let uid = s.edit("New Mask", None, |proj, st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?; + let k = masks.children.len(); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]); + let uid = g.uid; + masks.children.push(g.into()); + proj.next_id = next; + st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }]; + Ok(uid) + })?; + Ok(json!({"mask": uid})) +} + +fn add_vertex(s: &mut Session, p: &Value) -> Result { + let point = pt(p.get("point")).ok_or_else(|| bad("mask.addVertex", "missing `point` [x,y]"))?; + let tin = pt(p.get("in")).unwrap_or([0.0; 2]); + let tout = pt(p.get("out")).unwrap_or([0.0; 2]); + let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let (_, lid) = layer_p(s, p, "mask.addVertex")?; + let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| { + let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len()); + sp.vertices.insert(i, point); + sp.in_tangents.insert(i, tin); + sp.out_tangents.insert(i, tout); + Ok(i) + })?; + s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; + Ok(json!(i)) +} + +fn set_vertex(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.setVertex", "missing `index`"))? as usize; + let (point, tin, tout) = (pt(p.get("point")), pt(p.get("in")), pt(p.get("out"))); + edit_path(s, p, "mask.setVertex", |sp| { + if i >= sp.vertices.len() { + return Err(bad("mask.setVertex", "no such vertex")); + } + if let Some(v) = point { + sp.vertices[i] = v; + } + if let Some(v) = tin { + sp.in_tangents[i] = v; + } + if let Some(v) = tout { + sp.out_tangents[i] = v; + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn set_closed(s: &mut Session, p: &Value) -> Result { + let c = b_p(p, "closed").unwrap_or(true); + edit_path(s, p, "mask.setClosed", |sp| { + sp.closed = c; + Ok(()) + })?; + Ok(json!(c)) +} + +/// Selected (or given) vertices grouped per (layer, mask). +fn vertex_groups(s: &Session, p: &Value) -> Result>> { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut g: std::collections::BTreeMap<(u64, Uid), Vec> = Default::default(); + if let Some(Value::Array(a)) = p.get("vertices") { + for v in a { + let l = v.get("layer").and_then(|x| resolve_layer(comp, x)).ok_or_else(|| bad("mask", "vertex needs `layer`"))?; + let m = v.get("mask").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `mask` uid"))?; + let i = v.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `index`"))?; + g.entry((l.0, m)).or_default().push(i as usize); + } + } else { + for v in &s.state.selected_vertices { + g.entry((v.layer.0, v.mask)).or_default().push(v.index); + } + } + Ok(g) +} + +fn select_vertices(s: &mut Session, p: &Value) -> Result { + let g = vertex_groups(s, &json!({"vertices": p.get("vertices").cloned().unwrap_or(json!([]))}))?; + let sel: Vec = + g.into_iter().flat_map(|((l, m), is)| is.into_iter().map(move |i| VertexRef { layer: effectcraft_project::LayerId(l), mask: m, index: i })).collect(); + if b_p(p, "add").unwrap_or(false) { + for v in sel { + if let Some(i) = s.state.selected_vertices.iter().position(|x| *x == v) { + if b_p(p, "toggle").unwrap_or(false) { + s.state.selected_vertices.remove(i); + } + } else { + s.state.selected_vertices.push(v); + } + } + } else { + s.state.selected_vertices = sel; + } + Ok(json!(s.state.selected_vertices.len())) +} + +fn has_vertices(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_vertices.is_empty() { Err("select mask vertices first".into()) } else { Ok(()) } +} + +/// Apply `f` to the selected vertices' paths (each mask once). +fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let groups = vertex_groups(s, p)?; + let t = s.time(); + s.edit(label, merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for ((l, m), idx) in &groups { + let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue }; + let lt = layer.layer_time(t); + let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue }; + let KV::Path(mut sp) = pr.value_at(lt) else { continue }; + f(&mut sp, idx); + pr.set_value_at(lt, KV::Path(sp)); + } + Ok(()) + })?; + Ok(json!(groups.values().map(Vec::len).sum::())) +} + +fn move_vertices(s: &mut Session, p: &Value) -> Result { + let d = pt(p.get("delta")).ok_or_else(|| bad("mask.moveVertices", "missing `delta` [dx,dy] (layer space)"))?; + edit_vertices(s, p, "Move Mask Vertices", |sp, idx| { + for &i in idx { + if let Some(v) = sp.vertices.get_mut(i) { + v[0] += d[0]; + v[1] += d[1]; + } + } + }) +} + +fn delete_vertices(s: &mut Session, p: &Value) -> Result { + let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| { + let mut idx = idx.to_vec(); + idx.sort_unstable(); + idx.dedup(); + for &i in idx.iter().rev() { + if i < sp.vertices.len() { + sp.vertices.remove(i); + sp.in_tangents.remove(i); + sp.out_tangents.remove(i); + } + } + if sp.vertices.len() < 3 { + sp.closed = false; + } + })?; + s.state.selected_vertices.clear(); + Ok(n) +} + +fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result { + let (cid, lid) = layer_p(s, p, "mask.remove")?; + let key = p.get("mask").cloned(); + if !all && key.is_none() { + return Err(bad("mask.remove", "missing `mask`")); + } + s.edit(if all { "Remove All Masks" } else { "Remove Mask" }, None, |proj, st| { + let l: &mut Layer = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.remove", "this layer has no masks"))?; + if all { + masks.children.clear(); + } else if let Some(k) = &key { + let uid = find_mask(masks, k).map(|g| g.uid).ok_or_else(|| bad("mask.remove", "no such mask"))?; + masks.children.retain(|c| c.uid() != uid); + } + st.selected_vertices.retain(|v| v.layer != lid); + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask), + cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex), + cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex), + cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed), + cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices), + cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices), + cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), + cmd!("mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), + cmd!("mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index bb5dbd075..8eedad286 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -10,6 +10,7 @@ mod help; mod layer; mod layer_time; mod link; +mod mask; mod prop; mod query; mod time; @@ -75,6 +76,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(prop::specs()); v.extend(anim::specs()); v.extend(link::specs()); + v.extend(mask::specs()); v.extend(effect::specs()); v.extend(time::specs()); v.extend(help::specs()); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 9772a5a11..6edf3e33f 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -240,6 +240,58 @@ fn time_reverse_keyframes() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0))); } +#[test] +fn pen_tool_draws_edits_and_closes_a_mask() { + let (mut s, l) = setup(); + // Click (corner), click, drag (Bezier), click on the first vertex (close). + let r = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10]]})).unwrap(); + let m = r["mask"].as_u64().unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 10]})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 90]})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20], "merge": "pen"})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap(); + s.execute("mask.setClosed", json!({"layer": l, "mask": m, "closed": true})).unwrap(); + let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone(); + let p = path(&s); + assert_eq!(p.vertices, vec![[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0]]); + assert_eq!(p.out_tangents[2], [0.0, 20.0]); + assert!(p.closed); + assert_eq!(s.state.selected_vertices.last().unwrap().index, 3); + // The mask cuts the 100×100 solid (layer centred in the 400×300 comp at 150..250, 100..200). + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() }); + assert!(img.get(200, 150)[3] > 0.99 && img.get(152, 102)[3] < 0.01); + // Undo removes the close, redo restores it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(!path(&s).closed); + s.execute("edit.redo", json!({})).unwrap(); + // Select two vertices and drag them; Delete removes them. + s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap(); + s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap(); + s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap(); + assert_eq!(path(&s).vertices[1], [100.0, 0.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path(&s).vertices[1], [90.0, 10.0], "a drag is one undo step"); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(path(&s).vertices.len(), 2); + assert!(!path(&s).closed); + // Animated mask paths get a key at the current time. + s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": 1, "index": 0, "point": [0, 0]})).unwrap(); + assert_eq!(prop(&s, l, "masks/#1/path").keys.len(), 2); + // Mode / feather / opacity / expansion through the timeline commands. + s.execute("layer.setMask", json!({"layer": l, "mask": m, "mode": "Subtract", "inverted": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": [4, 4]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/expansion", "value": 3})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/opacity", "value": 50})).unwrap(); + assert_eq!(prop(&s, l, "masks/#1/opacity").value, KV::Scalar(50.0)); + s.execute("mask.removeAll", json!({"layer": l})).unwrap(); + assert!(s.active_comp().unwrap().layers[0].masks().unwrap().children.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1); +} + #[test] fn alt_click_stopwatch_toggles_a_self_reference_expression() { let (mut s, l) = setup(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 7c4dc7cc7..120498b86 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -55,13 +55,82 @@ pub fn last_fit(ctx: &egui::Context) -> f32 { /// What a drag in the viewer is doing. #[derive(Clone, Debug)] enum Gesture { - Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] }, - Scale { layer: LayerId, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3 }, - Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 }, - Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 }, - Pan { start_pan: [f32; 2] }, - Create { tool: Tool, start: [f64; 2] }, - Marquee { start: Pos2 }, + Move { + layers: Vec<(LayerId, [f64; 3], Mat3)>, + start: [f64; 2], + }, + Scale { + layer: LayerId, + start_scale: [f64; 3], + start_local: [f64; 2], + inv: Mat3, + }, + Rotate { + layer: LayerId, + center: Pos2, + start_angle: f64, + start_rot: f64, + }, + Anchor { + layer: LayerId, + start_anchor: [f64; 3], + start_pos: [f64; 3], + start: [f64; 2], + inv: Mat3, + l2p: Mat3, + }, + Pan { + start_pan: [f32; 2], + }, + Create { + tool: Tool, + start: [f64; 2], + }, + Marquee { + start: Pos2, + }, + /// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer). + Vertices { + last: [f64; 2], + inv: Mat3, + }, + /// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them). + Tangent { + layer: LayerId, + mask: u64, + index: usize, + out: bool, + vertex: [f64; 2], + inv: Mat3, + }, + /// Pen tool: pulling the tangents of the vertex just placed. + Pen { + layer: LayerId, + mask: u64, + index: usize, + vertex: [f64; 2], + inv: Mat3, + press: Pos2, + }, +} + +/// Pen tool path in progress: (layer, mask uid). +fn pen_id() -> egui::Id { + egui::Id::new("viewer-pen") +} + +/// Mask vertex / tangent hit targets drawn this frame. +#[derive(Clone, Copy)] +struct VertexHit { + layer: LayerId, + mask: u64, + index: usize, + pos: Pos2, + /// None = the vertex itself, Some(out?) = a tangent handle. + tangent: Option, + /// Layer-space vertex position and layer → comp matrix. + vertex: [f64; 2], + l2c: Mat3, } const HANDLE: f32 = 7.0; @@ -247,6 +316,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() }; let selected = app.session.state.selected_layers.clone(); let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); + let mut vertex_hits: Vec = Vec::new(); + let sel_vertices = app.session.state.selected_vertices.clone(); + // Pen path in progress ends when the tool changes or its mask is gone. + if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) { + ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id())); + } if app.ui.viewer.show_layer_controls { for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = Tokens::label(l.label); @@ -296,14 +371,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if sp.closed && !pts.is_empty() { pts.push(pts[0]); } - painter.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::from_rgb(color[0], color[1], color[2])))); - for v in &sp.vertices { - let c = m.apply(gv2(v[0], v[1])); - painter.rect_filled( - Rect::from_center_size(map.to_screen([c.x, c.y]), vec2(5.0, 5.0)), - 0.0, - Color32::from_rgb(color[0], color[1], color[2]), - ); + let mc = Color32::from_rgb(color[0], color[1], color[2]); + painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc))); + let scr = |p: [f64; 2]| { + let c = m.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + for (i, v) in sp.vertices.iter().enumerate() { + let s = scr(*v); + let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i); + // Selected vertices: filled with their tangent handles; others hollow. + if selected { + for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] { + let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue }; + let h = scr([v[0] + tg[0], v[1] + tg[1]]); + painter.line_segment([s, h], Stroke::new(1.0, mc)); + painter.circle_filled(h, 3.0, mc); + vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m }); + } + painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc); + } else { + painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160)); + painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); + } + app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name); + vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m }); + } + // Pen rubber band from the last vertex to the pointer. + if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid)) + && let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos())) + { + painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); } } } @@ -395,10 +493,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let mods = ui.input(|i| i.modifiers); let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input(); + // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the + // first vertex closes the path. + if app.ui.tool == Tool::Pen + && !space_pan + && resp.hovered() + && ui.input(|i| i.pointer.primary_pressed()) + && let Some(pos) = ui.input(|i| i.pointer.press_origin()) + { + pen_press(app, ui, &ectx, &map, &vertex_hits, pos); + } + // A pen click (press + release without a drag) ends its tangent gesture on release. + if ui.input(|i| i.pointer.primary_released()) + && matches!(ui.data(|d| d.get_temp::(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. })) + && !resp.drag_stopped() + { + ui.data_mut(|d| d.remove::(egui::Id::new("viewer-gesture"))); + } + let vertex_at = |pos: Pos2, tangents: bool| -> Option { + vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied() + }; if resp.drag_started() && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); + let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos); let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool }; let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), @@ -428,6 +547,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }) }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent { + layer: h.layer, + mask: h.mask, + index: h.index, + out: h.tangent == Some(true), + vertex: h.vertex, + inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY), + }), + Tool::Selection if vertex_at(press, false).is_some() => vertex_at(press, false).map(|h| { + let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}); + let selected = app.session.state.selected_vertices.iter().any(|v| v.layer == h.layer && v.mask == h.mask && v.index == h.index); + if !selected { + let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift})); + } + Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) } + }), Tool::Selection => { if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); @@ -552,6 +687,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_two_pos(a, pos); painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); } + Gesture::Vertices { last, inv } => { + let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1])); + if d.x != 0.0 || d.y != 0.0 { + let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge})); + } + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv })); + } + Gesture::Tangent { layer, mask, index, out, vertex, inv } => { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let t = [lp.x - vertex[0], lp.y - vertex[1]]; + let mut params = json!({"layer": layer.0, "mask": mask, "index": index, "merge": merge}); + params[if out { "out" } else { "in" }] = json!(t); + if !mods.alt { + params[if out { "in" } else { "out" }] = json!([-t[0], -t[1]]); + } + let _ = app.session.execute("mask.setVertex", params); + } + Gesture::Pen { layer, mask, index, vertex, inv, press } => { + if pos.distance(press) > 3.0 { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let t = [lp.x - vertex[0], lp.y - vertex[1]]; + let _ = app + .session + .execute("mask.setVertex", json!({"layer": layer.0, "mask": mask, "index": index, "out": t, "in": [-t[0], -t[1]], "merge": merge})); + } + } Gesture::Marquee { start } => { let r = Rect::from_two_pos(start, pos); painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12)); @@ -608,6 +769,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string()))); } } + Tool::Pen => {} + Tool::Selection if vertex_at(pos, false).is_some() => { + if let Some(h) = vertex_at(pos, false) { + let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}); + let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift})); + } + } _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); @@ -616,6 +784,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if resp.double_clicked() + && app.ui.tool != Tool::Pen && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); @@ -642,6 +811,51 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bottom_bar(app, ui, bar, zoom, fit, time, &comp); } +/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path. +fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) { + let gid = egui::Id::new("viewer-gesture"); + let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); + let target = current.map(|c| c.0).or_else(|| { + app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked)) + }); + let Some(lid) = target else { + app.ui.status = "Select a layer to draw a mask with the Pen tool".into(); + return; + }; + let Some(layer) = ectx.comp.layer(lid) else { return }; + let l2c = ectx.layer_to_comp(layer).0; + let Some(inv) = l2c.inverse() else { return }; + let c = map.to_comp(pos); + let lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid); + if let (true, Some((_, mask))) = (still_there, current) { + let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count(); + let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none()); + if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) { + let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true})); + ui.data_mut(|d| { + d.remove::<(LayerId, u64)>(pen_id()); + d.remove::(gid); + }); + return; + } + if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) { + let index = v.as_u64().unwrap_or(0) as usize; + ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos })); + } + return; + } + if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]})) + && let Some(mask) = v["mask"].as_u64() + { + ui.data_mut(|d| { + d.insert_temp(pen_id(), (lid, mask)); + d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos }); + }); + } +} + /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { let hit = ectx From 8599ac5828eb7748144166cb4325ce7db49380e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:09:59 -0400 Subject: [PATCH 30/32] M9.3: Simulation effects (CC Rainfall, CC Snowfall, CC Bubbles, CC Drizzle, CC Star Burst, CC Particle World, CC Particle Systems II, CC Mr. Mercury, CC Ball Action, CC Pixel Polly, CC Scatterize, Card Dance, Caustics, Wave World, Shatter, Foam) Closed-form weather/bubble/star effects; stepped particle, wave and foam sims through the checkpointed SimCache (seek-consistent); band-bucketed sprite/piece rasteriser and textured 3D pieces via per-piece inverse homography. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/sim.rs | 1529 +++++++++++++++++++++++++++++++++- crates/effects/src/sim2.rs | 1609 +++++++++++++++++++++++++++++++++++- 2 files changed, 3132 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index dc8d20edd..024a91fc0 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -1,7 +1,1530 @@ -//! Simulation effects (particles, physics, liquids). +//! Simulation effects (particles, physics, liquids): CC Rainfall, CC Snowfall, CC Bubbles, +//! CC Drizzle, CC Star Burst, CC Particle World, CC Particle Systems II and CC Mr. Mercury. +//! +//! Written from the public descriptions of the effects' behaviour. Weather/bubble/star effects +//! are closed-form functions of time (every frame independent); particle systems are stepped +//! from layer time 0 at a fixed rate through [`SimCache`], so any frame can be rendered +//! directly and equals the frame reached by scrubbing. +//! +//! Shared here (also used by `sim2`): a band-bucketed rasteriser for many small items +//! ([`raster`]), particle sprites ([`Sprite`]) and transfer-mode compositing ([`combine`]). -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::util::{SimCache, hash1, params_key, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } +} + +pub(crate) fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +pub(crate) fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +/// Deterministic hash → [0, 1). +#[inline] +pub(crate) fn h(i: u32, k: u32, seed: u32) -> f32 { + hash1(i, k, seed) +} + +/// Signed hash → [-1, 1). +#[inline] +pub(crate) fn hs(i: u32, k: u32, seed: u32) -> f32 { + hash1(i, k, seed) * 2.0 - 1.0 +} + +// ------------------------------------------------------------------ band rasteriser + +const BAND: usize = 16; + +/// How rasterised items combine with what is already in the target. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Acc { + /// Premultiplied "over" (later items on top). + Over, + /// Additive (glows, light). + Add, +} + +/// Rasterise many small items into `img` (buffer pixels). `bbox` gives an item's pixel bounds +/// `[x0, y0, x1, y1]` (or `None` to skip), `shade` its premultiplied colour at a pixel centre. +/// Items are bucketed into horizontal bands rendered in parallel; within a band they are drawn +/// in slice order. +pub(crate) fn raster( + img: &mut Image, + items: &[T], + bbox: impl Fn(&T) -> Option<[f32; 4]> + Sync, + shade: impl Fn(&T, f32, f32) -> Option + Sync, + acc: Acc, +) { + let (w, hgt) = (img.width as usize, img.height as usize); + if w == 0 || hgt == 0 || items.is_empty() { + return; + } + let nb = hgt.div_ceil(BAND); + let boxes: Vec> = items + .par_iter() + .map(|it| { + let b = bbox(it)?; + if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) { + return None; + } + let x0 = b[0].floor().max(0.0) as i32; + let y0 = b[1].floor().max(0.0) as i32; + let x1 = (b[2].ceil() as i32).min(w as i32 - 1); + let y1 = (b[3].ceil() as i32).min(hgt as i32 - 1); + if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 { + return None; + } + Some([x0, y0, x1, y1]) + }) + .collect(); + let mut buckets: Vec> = vec![Vec::new(); nb]; + for (i, b) in boxes.iter().enumerate() { + if let Some(b) = b { + for band in (b[1] as usize / BAND)..=(b[3] as usize / BAND).min(nb - 1) { + buckets[band].push(i as u32); + } + } + } + img.data.par_chunks_mut(w * BAND).enumerate().for_each(|(band, chunk)| { + let by0 = band * BAND; + let rows = chunk.len() / w; + for &i in &buckets[band] { + let Some(b) = boxes[i as usize] else { continue }; + let it = &items[i as usize]; + let ya = (b[1] as usize).max(by0); + let yb = (b[3] as usize).min(by0 + rows - 1); + for y in ya..=yb { + let row = &mut chunk[(y - by0) * w..(y - by0 + 1) * w]; + for x in b[0] as usize..=b[2] as usize { + if let Some(c) = shade(it, x as f32 + 0.5, y as f32 + 0.5) { + let d = &mut row[x]; + match acc { + Acc::Over => { + let k = 1.0 - c[3]; + *d = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k]; + } + Acc::Add => { + *d = [d[0] + c[0], d[1] + c[1], d[2] + c[2], (d[3] + c[3]).min(1.0)]; + } + } + } + } + } + } + }); +} + +// ------------------------------------------------------------------ sprites + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Shape { + /// Soft round dot (Gaussian-like falloff). + Soft, + /// Hard anti-aliased disc. + Disc, + /// Lit sphere (upper-left light, specular). + Sphere, + /// Disc fading linearly to its rim. + Faded, + /// Thin ring with a highlight. + Bubble, + /// Four-point star. + Star, + /// Rotated square (angle in `rot`). + Square, + /// Triangle (angle in `rot`). + Tri, + /// Segment from (x, y) by (dx, dy), half-width `r`. + Line { dx: f32, dy: f32 }, +} + +/// A particle sprite in buffer pixels; `c` is straight colour + opacity. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Sprite { + pub x: f32, + pub y: f32, + pub r: f32, + pub c: [f32; 4], + pub shape: Shape, + pub rot: f32, +} + +impl Sprite { + pub(crate) fn new(x: f32, y: f32, r: f32, c: [f32; 4], shape: Shape) -> Sprite { + Sprite { x, y, r, c, shape, rot: 0.0 } + } +} + +fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { + if s.c[3] <= 1e-5 || s.r <= 0.0 { + return None; + } + let r = s.r.max(0.5) + 1.0; + Some(match s.shape { + Shape::Line { dx, dy } => [s.x.min(s.x + dx) - r, s.y.min(s.y + dy) - r, s.x.max(s.x + dx) + r, s.y.max(s.y + dy) + r], + Shape::Star => [s.x - r * 2.0, s.y - r * 2.0, s.x + r * 2.0, s.y + r * 2.0], + _ => [s.x - r, s.y - r, s.x + r, s.y + r], + }) +} + +#[inline] +fn aa(edge: f32) -> f32 { + (edge + 0.5).clamp(0.0, 1.0) +} + +fn sprite_shade(s: &Sprite, x: f32, y: f32) -> Option { + // Sub-pixel sprites keep their energy: draw at radius 0.5 with reduced opacity. + let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) }; + let (dx, dy) = (x - s.x, y - s.y); + let mut rgb = [s.c[0], s.c[1], s.c[2]]; + let cov = match s.shape { + Shape::Soft => { + let d2 = (dx * dx + dy * dy) / (r * r); + if d2 >= 1.0 { + return None; + } + let q = 1.0 - d2; + q * q + } + Shape::Disc => aa(r - (dx * dx + dy * dy).sqrt()), + Shape::Faded => { + let d = (dx * dx + dy * dy).sqrt() / r; + (1.0 - d).max(0.0).powf(1.5) + } + Shape::Sphere => { + let d = (dx * dx + dy * dy).sqrt(); + let cv = aa(r - d); + if cv <= 0.0 { + return None; + } + let (nx, ny) = (dx / r, dy / r); + let nz = (1.0 - nx * nx - ny * ny).max(0.0).sqrt(); + let (lx, ly, lz) = (-0.48, -0.58, 0.66); + let diff = (nx * lx + ny * ly + nz * lz).max(0.0); + let rz = 2.0 * diff * nz - lz; + let spec = rz.max(0.0).powi(20) * 0.6; + let k = 0.2 + 0.8 * diff; + rgb = [rgb[0] * k + spec, rgb[1] * k + spec, rgb[2] * k + spec]; + cv + } + Shape::Bubble => { + let d = (dx * dx + dy * dy).sqrt() / r; + if d > 1.0 + 1.0 / r { + return None; + } + let ring = (1.0 - ((d - 0.9) / 0.12).powi(2)).max(0.0); + let hl = (1.0 - (((dx / r) + 0.4).powi(2) + ((dy / r) + 0.4).powi(2)) / 0.04).max(0.0); + (0.12 * (d <= 1.0) as u8 as f32 + ring * 0.8 + hl).min(1.0) + } + Shape::Star => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = ((dx * cs + dy * sn) / r, (-dx * sn + dy * cs) / r); + let d = (ux * ux + uy * uy).sqrt(); + let core = (1.0 - d).max(0.0).powi(2); + let sx = (1.0 - ux.abs() / 2.0).max(0.0) * (1.0 - (uy.abs() * 6.0)).max(0.0); + let sy = (1.0 - uy.abs() / 2.0).max(0.0) * (1.0 - (ux.abs() * 6.0)).max(0.0); + (core + sx + sy).min(1.0) + } + Shape::Square => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let e = r * 0.75; + aa(e - ux.abs()) * aa(e - uy.abs()) + } + Shape::Tri => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + // Equilateral triangle with circumradius r: three half-planes. + let e = r * 0.5; + let mut m = f32::INFINITY; + for k in 0..3 { + let a = std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::TAU / 3.0; + let (ns, nc) = a.sin_cos(); + m = m.min(e - (ux * nc + uy * ns)); + } + aa(m) + } + Shape::Line { dx: lx, dy: ly } => { + let l2 = lx * lx + ly * ly; + let t = if l2 > 0.0 { ((dx * lx + dy * ly) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (px, py) = (dx - lx * t, dy - ly * t); + aa(r - (px * px + py * py).sqrt()) + } + }; + let a = cov * s.c[3] * fade; + if a <= 1e-6 { + return None; + } + Some([rgb[0] * a, rgb[1] * a, rgb[2] * a, a]) +} + +/// Draw sprites into a new transparent image of `w`×`h`. +pub(crate) fn splat(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc) -> Image { + let mut img = Image::new(w, hgt); + raster(&mut img, sprites, sprite_bbox, sprite_shade, acc); + img +} + +/// Combine an effect layer `fx` with the original `src` (both premultiplied, same size): +/// 0 Composite (fx over src), 1 Screen, 2 Add, 3 Lighten, 4 Darken, 5 fx only. +pub(crate) fn combine(src: &Image, fx: &Image, mode: u32) -> Image { + let mut out = fx.clone(); + out.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s)| { + let f = *o; + *o = match mode { + 0 => { + let k = 1.0 - f[3]; + [f[0] + s[0] * k, f[1] + s[1] * k, f[2] + s[2] * k, f[3] + s[3] * k] + } + 1 => [s[0] + f[0] - s[0] * f[0], s[1] + f[1] - s[1] * f[1], s[2] + f[2] - s[2] * f[2], s[3] + f[3] - s[3] * f[3]], + 2 => [s[0] + f[0], s[1] + f[1], s[2] + f[2], (s[3] + f[3]).min(1.0)], + 3 => [s[0].max(f[0]), s[1].max(f[1]), s[2].max(f[2]), s[3] + f[3] - s[3] * f[3]], + 4 => { + let (fc, fa) = unpremul(f); + let m = |c: f32, k: usize| c - (c - c.min(fc[k] * s[3])) * fa; + [m(s[0], 0), m(s[1], 1), m(s[2], 2), s[3]] + } + _ => f, + }; + }); + out +} + +// ------------------------------------------------------------------ CC Rainfall / CC Snowfall + +/// Shared weather layout: per-item depth factor `k` in (0, 1] (1 = nearest) from Scene Depth. +#[inline] +fn depth_k(z: f32, scene_depth: f32) -> f32 { + 1.0 / (1.0 + z * scene_depth / 2500.0) +} + +/// Wrap `v` into `[lo, lo + span)`. +#[inline] +fn wrap(v: f32, lo: f32, span: f32) -> f32 { + if span <= 0.0 { v } else { lo + (v - lo).rem_euclid(span) } +} + +fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { + let pr = ctx.params; + let n = pr.f("drops").clamp(0.0, 100_000.0) as u32; + let size = pr.f("size").max(0.0) as f32; + let depth = pr.f("sceneDepth").max(0.0) as f32; + let speed = pr.f("speed") as f32; + let wind = pr.f("wind") as f32; + let wvar = pr.f("windVariation") as f32 / 100.0; + let spread = pr.f("spread") as f32; + let color = pr.color("color"); + let opacity = pr.f("opacity") as f32 / 100.0; + let mode = pr.e("transferMode"); + let with_orig = pr.b("compositeWithOriginal"); + let refract = pr.e("appearance") == 0; + let off = pr.v2("offset"); + let seed = pr.f("randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x9e37); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let src = &b.img; + let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); + let sprites: Vec = (0..n) + .into_par_iter() + .filter_map(|i| { + let z = h(i, 1, seed); + let k = depth_k(z, depth); + let vy = speed / 4000.0 * lh * 2.2 * k; + let w = wind / 1000.0 * lh * (1.0 + wvar * hs(i, 2, seed)) * k + spread * hs(i, 7, seed) * k; + let len = (vy * 0.04 * size.max(0.2)).max(1.0); + let lenx = w * 0.04 * size.max(0.2); + let margin = len + lenx.abs() + 4.0; + let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); + let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin); + let (bx, by) = b.to_px([x as f64, y as f64]); + let mut c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; + if refract { + // Refracting drops show the scene behind them, brightened and offset. + let (sc, sa) = unpremul(src.sample_bilinear(bx + 2.0 * s as f64, by)); + let m = |a: f32, cc: f32| (a * 0.6 + 0.5) * cc; + if sa > 0.0 { + c = [m(sc[0], color[0]), m(sc[1], color[1]), m(sc[2], color[2]), c[3]]; + } + } + let r = (size * 0.6 * k * s).max(0.25); + Some(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s })) + }) + .collect(); + weather_out(b, &sprites, mode, with_orig) +} + +fn weather_out(mut b: Buf, sprites: &[Sprite], mode: u32, with_orig: bool) -> Buf { + let fx = splat(b.img.width, b.img.height, sprites, Acc::Over); + b.img = if with_orig { combine(&b.img, &fx, if mode == 1 { 3 } else { 0 }) } else { fx }; + b +} + +fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { + let pr = ctx.params; + let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32; + let size = pr.f("size").max(0.0) as f32; + let svar = pr.f("sizeVariation") as f32 / 100.0; + let depth = pr.f("sceneDepth").max(0.0) as f32; + let speed = pr.f("speed") as f32; + let spvar = pr.f("speedVariation") as f32 / 100.0; + let wind = pr.f("wind") as f32; + let wvar = pr.f("windVariation") as f32 / 100.0; + let spread = pr.f("spread") as f32; + let wig = pr.f("wiggleAmount") as f32; + let wigvar = pr.f("wiggleAmountVariation") as f32 / 100.0; + let wfreq = pr.f("wiggleFrequency") as f32; + let wfvar = pr.f("wiggleFrequencyVariation") as f32 / 100.0; + let color = pr.color("color"); + let opacity = pr.f("opacity") as f32 / 100.0; + let mode = pr.e("transferMode"); + let with_orig = pr.b("compositeWithOriginal"); + let off = pr.v2("offset"); + let seed = pr.f("randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x85eb); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); + let sprites: Vec = (0..n) + .into_par_iter() + .map(|i| { + let z = h(i, 1, seed); + let k = depth_k(z, depth); + let r = (size * 0.5 * k * (1.0 + svar * hs(i, 2, seed))).max(0.05); + let vy = speed * (lh / 600.0) * k * (1.0 + spvar * hs(i, 3, seed)); + let vx = wind * (lh / 1200.0) * k * (1.0 + wvar * hs(i, 4, seed)) + spread * hs(i, 8, seed) * k; + let amp = wig * 2.0 * k * (1.0 + wigvar * hs(i, 5, seed)); + let fr = wfreq * (1.0 + wfvar * hs(i, 6, seed)); + let ph = h(i, 9, seed) * std::f32::consts::TAU; + let margin = r + amp.abs() + 2.0; + let y = wrap(h(i, 10, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); + let x = wrap(h(i, 11, seed) * (lw + 2.0 * margin) + vx * t + ox, -margin, lw + 2.0 * margin) + amp * (fr * t * std::f32::consts::TAU + ph).sin(); + let (bx, by) = b.to_px([x as f64, y as f64]); + Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft) + }) + .collect(); + weather_out(b, &sprites, mode, with_orig) +} + +// ------------------------------------------------------------------ CC Bubbles + +struct Bubble { + x: f32, + y: f32, + r: f32, + /// Source sample centre (buffer px). + sx: f32, + sy: f32, +} + +fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32; + let speed = pr.f("bubbleSpeed") as f32; + let wamp = pr.f("wobbleAmplitude") as f32; + let wfreq = pr.f("wobbleFrequency") as f32; + let bsize = pr.f("bubbleSize").max(0.0) as f32; + let metal = pr.e("reflectionType") == 1; + let shading = pr.e("shadingType"); + let seed = ctx.seed.wrapping_mul(0x27d4); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let base = lw.min(lh) * 0.035 * bsize; + let list: Vec = (0..n) + .map(|i| { + let r = base * (0.5 + h(i, 1, seed)); + let travel = lh + 2.0 * r; + let v = speed * lh * 0.3 * (0.6 + 0.8 * h(i, 2, seed)); + let y = lh + r - (h(i, 3, seed) * travel + v * t).rem_euclid(travel); + let x0 = h(i, 4, seed) * lw; + let x = x0 + wamp * (r / base.max(1e-3)) * (t * wfreq * std::f32::consts::TAU + h(i, 5, seed) * std::f32::consts::TAU).sin(); + let (bx, by) = b.to_px([x as f64, y as f64]); + let (sx, sy) = b.to_px([x0 as f64, y as f64]); + Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 } + }) + .collect(); + let src = b.img.clone(); + let mut out = Image::new(src.width, src.height); + raster( + &mut out, + &list, + |q| Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), + |q, x, y| { + let (dx, dy) = ((x - q.x) / q.r.max(0.5), (y - q.y) / q.r.max(0.5)); + let d2 = dx * dx + dy * dy; + let d = d2.sqrt(); + let cov = aa((1.0 - d) * q.r); + if cov <= 0.0 { + return None; + } + // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). + let k = if metal { 1.6 } else { -0.7 }; + let nz = (1.0 - d2).max(0.0).sqrt(); + let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let (c, sa) = unpremul(p); + let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; + let mut a = cov; + match shading { + 1 => c = c.map(|v| v * 0.7 + 0.3 * (1.0 - nz)), + 2 => c = c.map(|v| v * (0.5 + 0.5 * nz)), + 3 => a *= nz.powf(0.5), + 4 => a *= 1.0 - nz * 0.8, + _ => {} + } + let hl = (1.0 - ((dx + 0.35).powi(2) + (dy + 0.35).powi(2)) / 0.05).max(0.0) * 0.8; + let c = c.map(|v| v + hl); + Some([c[0] * a, c[1] * a, c[2] * a, a]) + }, + Acc::Over, + ); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Drizzle + +fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let rate = pr.f("dripRate").max(0.0); + let life = pr.f("longevity").max(0.01); + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let disp = pr.f("displacement") as f32; + let height = pr.f("rippleHeight") as f32 / 100.0; + let spreading = pr.f("spreading") as f32; + let li = pr.f("lightIntensity") as f32 / 100.0; + let lc = pr.color("lightColor"); + let lheight = pr.f("lightHeight") as f32 / 100.0; + let ldir = (pr.f("lightDirection") as f32).to_radians(); + let ambient = pr.f("ambient") as f32 / 100.0; + let diffuse = pr.f("diffuse") as f32 / 100.0; + let specular = pr.f("specular") as f32 / 100.0; + let rough = pr.f("roughness").max(0.001) as f32; + let seed = ctx.seed.wrapping_mul(0x165667b1); + let t = ctx.time; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + if rate <= 0.0 || t < 0.0 { + return b; + } + // Drops born at k / rate with hashed positions; alive while younger than the longevity. + let k0 = ((t - life) * rate).floor().max(0.0) as u32; + let k1 = (t * rate).floor() as u32; + let mut drops = Vec::new(); + for k in k0..=k1 { + let born = k as f64 / rate; + let age = (t - born) as f32; + if age < 0.0 || age as f64 >= life { + continue; + } + let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]); + let radius = spreading * age * lh / 400.0 * s; + let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed)); + drops.push((x as f32, y as f32, radius, amp)); + } + let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); + let width = wavelen * (1.0 + rippling * 2.0); + let field = |x: f32, y: f32| -> f32 { + let mut v = 0.0; + for &(dx, dy, r, a) in &drops { + let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt(); + let u = (d - r) / width; + if u.abs() < 1.0 { + let env = 0.5 + 0.5 * (u * std::f32::consts::PI).cos(); + v += a * env * ((d - r) / wavelen * std::f32::consts::TAU).cos(); + } + } + v + }; + let src = b.img.clone(); + let (ls, lcs) = ldir.sin_cos(); + let l = { + let v = [lcs, ls, lheight.max(0.05)]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / n, v[1] / n, v[2] / n] + }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let gx = field(fx + 1.0, fy) - field(fx - 1.0, fy); + let gy = field(fx, fy + 1.0) - field(fx, fy - 1.0); + if gx == 0.0 && gy == 0.0 { + continue; + } + let p = src.sample_bilinear_clamped((fx - gx * disp * s * 4.0) as f64, (fy - gy * disp * s * 4.0) as f64); + let n = { + let v = [-gx * 6.0, -gy * 6.0, 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let hz = (l[2] + 1.0) * 0.5; + let hv = { + let v = [l[0] * 0.5, l[1] * 0.5, hz]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let sp = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; + let shade = 1.0 + (ambient + diffuse * nl - diffuse * l[2]) * li; + let a = p[3]; + *px = [p[0] * shade + sp * lc[0] * a, p[1] * shade + sp * lc[1] * a, p[2] * shade + sp * lc[2] * a, a]; + } + }); + b +} + +// ------------------------------------------------------------------ CC Star Burst + +fn star_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let speed = pr.f("speed") as f32; + let phase = pr.f("phase") as f32 / 360.0; + let spacing = pr.f("gridSpacing").max(1.0) as f32; + let size = pr.f("size") as f32 / 100.0; + let blend = pr.f("blendWithOriginal") as f32 / 100.0; + let seed = ctx.seed.wrapping_mul(0x3c6e); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let nx = (lw / spacing).ceil().max(1.0) as u32; + let ny = (lh / spacing).ceil().max(1.0) as u32; + let src = b.img.clone(); + let (cx, cy) = (lw * 0.5, lh * 0.5); + let mut list: Vec<(f32, Sprite)> = (0..nx * ny) + .into_par_iter() + .filter_map(|i| { + let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5); + let (sx, sy) = b.to_px([gx as f64, gy as f64]); + let (c, a) = unpremul(src.sample_bilinear(sx, sy)); + if a <= 0.0 { + return None; + } + // Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z). + let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0); + let persp = 1.0 / (1.0 - 0.9 * z); + let jx = hs(i, 2, seed) * scatter * 0.01 * lw * 0.5; + let jy = hs(i, 3, seed) * scatter * 0.01 * lh * 0.5; + let x = cx + (gx - cx + jx) * persp; + let y = cy + (gy - cy + jy) * persp; + let (bx, by) = b.to_px([x as f64, y as f64]); + let r = spacing * 0.5 * size * persp * s; + let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0); + Some((z, Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a * fade], Shape::Disc))) + }) + .collect(); + list.sort_by(|a, b| a.0.total_cmp(&b.0)); + let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); + let fx = splat(src.width, src.height, &sprites, Acc::Over); + b.img = fx; + if blend > 0.0 { + b.img.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s0)| { + *o = crate::util::lerp4(*o, *s0, blend); + }); + } + b +} + +// ------------------------------------------------------------------ particle engine + +/// Steps per second of the stepped particle simulations. +pub(crate) const SPS: f64 = 60.0; + +/// Number of whole steps reached at layer time `t`. +pub(crate) fn steps_at(t: f64) -> u64 { + if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } +} + +#[derive(Clone, Debug)] +pub(crate) struct Particle { + pub p: [f32; 3], + pub v: [f32; 3], + pub age: f32, + pub life: f32, + /// Per-particle random in [0, 1) (size variation, colour jitter…). + pub rnd: f32, + pub id: u32, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct PState { + pub parts: Vec, + pub carry: f64, + pub next_id: u32, +} + +/// Physics configuration (world units; y down). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Phys { + /// Particles per second. + pub rate: f64, + pub life: f32, + pub producer: [f32; 3], + pub radius: [f32; 3], + pub anim: u32, + pub speed: f32, + pub gravity: [f32; 3], + pub resistance: f32, + pub axis: [f32; 3], + pub extra: f32, + pub extra_angle: f32, + pub seed: u32, + pub max: usize, + /// 2D systems keep z = 0. + pub flat: bool, +} + +pub(crate) const PW_ANIMS: &[&str] = + &["Explosive", "Direction Axis", "Cone Axis", "Viscouse", "Twirly", "Vortex", "Fire", "Jet Sideways", "Fractal Omni", "Fractal Uni"]; + +fn norm3(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] } +} + +fn rand_dir(i: u32, k: u32, seed: u32, flat: bool) -> [f32; 3] { + if flat { + let a = h(i, k, seed) * std::f32::consts::TAU; + return [a.cos(), a.sin(), 0.0]; + } + let z = hs(i, k, seed); + let a = h(i, k + 1, seed) * std::f32::consts::TAU; + let r = (1.0 - z * z).max(0.0).sqrt(); + [r * a.cos(), r * a.sin(), z] +} + +impl Phys { + fn spawn(&self, id: u32) -> Particle { + let seed = self.seed; + let mut p = [0.0f32; 3]; + // Uniform in the producer ellipsoid. + let u = rand_dir(id, 1, seed, self.flat); + let rr = h(id, 3, seed).powf(if self.flat { 0.5 } else { 1.0 / 3.0 }); + for k in 0..3 { + p[k] = self.producer[k] + u[k] * self.radius[k] * rr; + } + if self.flat { + p[2] = 0.0; + } + let ax = norm3(self.axis); + let jitter = rand_dir(id, 5, seed, self.flat); + let dir = match self.anim { + 1 | 9 => norm3([ax[0] + jitter[0] * 0.08, ax[1] + jitter[1] * 0.08, ax[2] + jitter[2] * 0.08]), + 2 => { + let k = (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0); + norm3([ax[0] + jitter[0] * k * h(id, 8, seed), ax[1] + jitter[1] * k * h(id, 8, seed), ax[2] + jitter[2] * k * h(id, 8, seed)]) + } + 5 => [jitter[0], 0.0, if self.flat { 0.0 } else { jitter[2] }], + 6 => norm3([jitter[0] * 0.25, -1.0, jitter[2] * 0.25]), + 7 => norm3([1.0, jitter[1] * 0.05, jitter[2] * 0.05]), + _ => jitter, + }; + let sp = self.speed * (0.5 + 0.5 * h(id, 9, seed)); + let mut v = [dir[0] * sp, dir[1] * sp, dir[2] * sp]; + if self.flat { + v[2] = 0.0; + } + let life = self.life * (0.8 + 0.4 * h(id, 10, seed)); + Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id } + } + + pub(crate) fn step(&self, st: &mut PState, step: u64) { + let dt = (1.0 / SPS) as f32; + let t = step as f32 * dt; + let mut drag = self.resistance; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let damp = (-drag * dt).exp(); + let seed = self.seed; + st.parts.par_iter_mut().for_each(|q| { + let mut a = self.gravity; + match self.anim { + 4 => { + // Twirly: swirl around the vertical axis through the producer. + let (dx, dz) = (q.p[0] - self.producer[0], q.p[2] - self.producer[2]); + let k = 4.0 * self.extra; + a[0] += -dz * k; + a[2] += dx * k; + if self.flat { + let dy = q.p[1] - self.producer[1]; + a[0] += -dy * k; + a[1] += dx * k; + } + } + 5 => { + // Vortex: strong tangential + inward pull. + let (dx, dy) = (q.p[0] - self.producer[0], q.p[1] - self.producer[1]); + let k = 6.0 * self.extra; + a[0] += -dy * k - dx * 2.0; + a[1] += dx * k - dy * 2.0; + } + 6 => { + // Fire: buoyant, flickering turbulence. + let n = value_noise(q.p[0] * 8.0, q.p[1] * 8.0, t * 3.0, seed) - 0.5; + a[0] += n * 4.0 * self.extra; + a[1] -= self.gravity[1] * 1.5 + 0.3 * self.extra; + } + 8 | 9 => { + let f = 6.0 * self.extra; + a[0] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + a[1] += (value_noise(q.p[0] * 5.0 + 17.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + if !self.flat { + a[2] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0 + 31.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + } + } + _ => {} + } + for k in 0..3 { + q.v[k] = (q.v[k] + a[k] * dt) * damp; + q.p[k] += q.v[k] * dt; + } + if self.flat { + q.p[2] = 0.0; + q.v[2] = 0.0; + } + q.age += dt; + }); + st.parts.retain(|q| q.age < q.life); + st.carry += self.rate / SPS; + let n = st.carry.floor() as u32; + st.carry -= n as f64; + for _ in 0..n { + if st.parts.len() >= self.max { + break; + } + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + // Sub-step birth offset keeps continuous streams smooth. + let mut q = self.spawn(id); + let frac = h(id, 12, seed) * dt; + for k in 0..3 { + q.p[k] += q.v[k] * frac; + } + q.age = frac; + st.parts.push(q); + } + } +} + +/// Run a particle system to layer time `t` through `cache`. +pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { + cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) +} + +pub(crate) const PARTICLE_TYPES: &[&str] = + &["Line", "Shaded Sphere", "Faded Sphere", "Bubble", "Star", "Motion Polygon", "Tri-Polygon", "Cube", "Textured Square"]; + +/// Colour/size/opacity of a particle at its age. +fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsize: f32, dsize: f32, svar: f32, max_op: f32) -> ([f32; 4], f32) { + let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); + let size = (bsize + (dsize - bsize) * u) * (1.0 + svar * (q.rnd * 2.0 - 1.0)); + let fade = (u * 20.0).min(1.0) * ((1.0 - u) * 5.0).min(1.0); + let c = [birth[0] + (death[0] - birth[0]) * u, birth[1] + (death[1] - birth[1]) * u, birth[2] + (death[2] - birth[2]) * u, max_op * fade]; + (c, size.max(0.0)) +} + +fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite { + let vl = (vx * vx + vy * vy).sqrt().max(1e-6); + let shape = match kind { + 0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 }, + 1 => Shape::Sphere, + 2 => Shape::Faded, + 3 => Shape::Bubble, + 4 => Shape::Star, + 5 => Shape::Line { dx: -vx * 0.05, dy: -vy * 0.05 }, + 6 => Shape::Tri, + _ => Shape::Square, + }; + let rr = match kind { + 0 => (r * 0.08).max(0.5), + 5 => r * 0.5, + _ => r, + }; + Sprite { x, y, r: rr, c, shape, rot } +} + +// ------------------------------------------------------------------ CC Particle World + +static PW_CACHE: SimCache = SimCache::new(6); + +fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let phys = Phys { + rate: pr.f("birthRate").max(0.0) * 120.0, + life: pr.f("longevity").max(0.0) as f32, + producer: [pr.f("producerX") as f32, pr.f("producerY") as f32, pr.f("producerZ") as f32], + radius: [pr.f("radiusX").max(0.0) as f32, pr.f("radiusY").max(0.0) as f32, pr.f("radiusZ").max(0.0) as f32], + anim: pr.e("animation"), + speed: pr.f("velocity") as f32 * 0.5, + gravity: { + let g = pr.f("gravity") as f32; + let gv = [pr.f("gravityX") as f32, pr.f("gravityY") as f32, pr.f("gravityZ") as f32]; + [gv[0] * g, gv[1] * g, gv[2] * g] + }, + resistance: pr.f("resistance").max(0.0) as f32, + axis: [pr.f("axisX") as f32, pr.f("axisY") as f32, pr.f("axisZ") as f32], + extra: pr.f("extra") as f32, + extra_angle: pr.f("extraAngle") as f32, + seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, + max: 40_000, + flat: false, + }; + if phys.rate <= 0.0 && ctx.time <= 0.0 { + return b; + } + let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &phys); + let kind = pr.e("particleType"); + let birth = pr.color("birthColor"); + let death = pr.color("deathColor"); + let (bs, ds) = (pr.f("birthSize") as f32, pr.f("deathSize") as f32); + let svar = pr.f("sizeVariation") as f32 / 100.0; + let max_op = pr.f("maxOpacity") as f32 / 100.0; + let s = b.scale as f32; + // Camera at distance 2 (world units = layer width) looking at the origin (layer centre). + let cam = 2.0f32; + let mut list: Vec<(f32, Sprite)> = st + .parts + .par_iter() + .filter_map(|q| { + let zc = cam + q.p[2]; + if zc <= 0.05 { + return None; + } + let k = cam / zc; + let x = lw * 0.5 + q.p[0] * lw * k; + let y = lh * 0.5 + q.p[1] * lw * k; + let (bx, by) = b.to_px([x as f64, y as f64]); + let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op); + let r = size * 0.5 * lw * k * s * 0.25; + let sp = type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0); + Some((zc, sp)) + }) + .collect(); + list.sort_by(|a, b| b.0.total_cmp(&a.0)); + let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); + let mode = pr.e("transferMode"); + let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over }); + b.img = combine( + &b.img, + &fx, + match mode { + 0 => 0, + 1 => 1, + 2 => 2, + 3 => 5, + _ => 0, + }, + ); + b +} + +// ------------------------------------------------------------------ CC Particle Systems II + +static PS2_CACHE: SimCache = SimCache::new(6); + +fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let pos = pr.v2("position"); + let dir = (pr.f("direction") as f32).to_radians(); + let phys = Phys { + rate: pr.f("birthRate").max(0.0) * 60.0, + life: pr.f("longevity").max(0.0) as f32, + producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], + radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0], + anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("animation").min(9) as usize], + speed: pr.f("velocity") as f32 * 0.25, + gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0], + resistance: pr.f("resistance").max(0.0) as f32, + // AE angles: 0° = up, clockwise. + axis: [dir.sin(), -dir.cos(), 0.0], + extra: pr.f("extra") as f32, + extra_angle: 45.0 * pr.f("extra") as f32, + seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, + max: 40_000, + flat: true, + }; + let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &phys); + let kind = pr.e("particleType"); + let birth = pr.color("birthColor"); + let death = pr.color("deathColor"); + let (bs, ds) = (pr.f("birthSize") as f32, pr.f("deathSize") as f32); + let svar = pr.f("sizeVariation") as f32 / 100.0; + let max_op = pr.f("maxOpacity") as f32 / 100.0; + let s = b.scale as f32; + let sprites: Vec = st + .parts + .iter() + .map(|q| { + let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); + let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op); + let r = size * unit * 0.02 * s; + type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0) + }) + .collect(); + let mode = pr.e("transferMode"); + let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over }); + b.img = combine( + &b.img, + &fx, + match mode { + 0 => 0, + 1 => 1, + 2 => 2, + 3 => 5, + _ => 0, + }, + ); + b +} + +// ------------------------------------------------------------------ CC Mr. Mercury + +static MERCURY_CACHE: SimCache = SimCache::new(6); + +struct Blob { + x: f32, + y: f32, + r: f32, +} + +fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let pos = pr.v2("producer"); + let dir = (pr.f("direction") as f32).to_radians(); + let phys = Phys { + rate: pr.f("birthRate").max(0.0) * 10.0, + life: pr.f("longevity").max(0.0) as f32, + producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], + radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0], + anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("animation").min(9) as usize], + speed: pr.f("velocity") as f32 * 0.3, + gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0], + resistance: pr.f("resistance").max(0.0) as f32, + axis: [dir.sin(), -dir.cos(), 0.0], + extra: pr.f("extra") as f32, + extra_angle: 45.0 * pr.f("extra") as f32, + seed: ctx.seed.wrapping_mul(0xc2b2ae35), + max: 4_000, + flat: true, + }; + let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); + let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); + let influence = pr.f("blobInfluence") as f32 / 100.0; + let s = b.scale as f32; + let blobs: Vec = st + .parts + .iter() + .map(|q| { + let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); + let size = bs + (ds - bs) * u; + let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); + Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) } + }) + .filter(|q| q.r > 0.1) + .collect(); + let (w, hh) = (b.img.width, b.img.height); + // Field and gradient accumulate additively: [f, gx, gy, _]. + let mut field = Image::new(w, hh); + let infl = 1.0 + influence; + raster( + &mut field, + &blobs, + |q| { + let e = q.r * 2.0 * infl; + Some([q.x - e, q.y - e, q.x + e, q.y + e]) + }, + |q, x, y| { + let (dx, dy) = (x - q.x, y - q.y); + let rr = q.r * 2.0 * infl; + let d2 = (dx * dx + dy * dy) / (rr * rr); + if d2 >= 1.0 { + return None; + } + let k = 1.0 - d2; + let f = k * k * k; + let g = -6.0 * k * k / (rr * rr); + Some([f, g * dx, g * dy, 0.0]) + }, + Acc::Add, + ); + let thr = 0.35f32; + let ambient = pr.f("ambient") as f32 / 100.0; + let diffuse = pr.f("diffuse") as f32 / 100.0; + let specular = pr.f("specular") as f32 / 100.0; + let rough = pr.f("roughness").max(0.001) as f32; + let metal = pr.f("metal") as f32 / 100.0; + let li = pr.f("lightIntensity") as f32 / 100.0; + let lc = pr.color("lightColor"); + let ldir = (pr.f("lightDirection") as f32).to_radians(); + let lheight = pr.f("lightHeight") as f32 / 100.0; + let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]); + let src = b.img.clone(); + let mut out = Image::new(w, hh); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let fv = field.data[y * w as usize + x]; + let f = fv[0]; + if f <= thr * 0.8 { + continue; + } + let cov = ((f - thr * 0.8) / (thr * 0.4)).clamp(0.0, 1.0); + let n = norm3([fv[1] * 3.0, fv[2] * 3.0, 1.0 / (1.0 + f)]); + // Refract the layer through the blob. + let sp = src.sample_bilinear_clamped(x as f64 + 0.5 - n[0] as f64 * 12.0 * s as f64, y as f64 + 0.5 - n[1] as f64 * 12.0 * s as f64); + let (c, sa) = unpremul(sp); + let base = if sa > 0.0 { c } else { [0.7, 0.7, 0.75] }; + let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let hv = norm3([l[0], l[1], l[2] + 1.0]); + let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; + let shade = ambient + diffuse * nl * li; + let cc: [f32; 3] = std::array::from_fn(|k| base[k] * shade + spec * (lc[k] * (1.0 - metal) + base[k] * metal)); + *px = [cc[0] * cov, cc[1] * cov, cc[2] * cov, cov]; + } + }); + b.img = out; + b +} + +// ------------------------------------------------------------------ specs pub fn specs() -> Vec { - Vec::new() + let transfer = || popup(&["Composite", "Lighten"]); + vec![ + spec( + "ec.sim.ccrainfall", + "CC Rainfall", + vec![ + p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)), + p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), + p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("speed", "Speed", num(4000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("wind", "Wind", num(0.0), slider(-10_000.0, 10_000.0, -2000.0, 2000.0, 1)), + p("windVariation", "Variation % (Wind)", num(0.0), pct()), + p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("color", "Color", col(0.8, 0.8, 0.8), ParamUi::Color), + p("opacity", "Opacity", num(25.0), pct()), + p("transferMode", "Transfer Mode", Value::Enum(1), transfer()), + p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), + p("appearance", "Appearance", Value::Enum(0), popup(&["Refracting", "Soft Solid"])), + p("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + rainfall, + ), + spec( + "ec.sim.ccsnowfall", + "CC Snowfall", + vec![ + p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)), + p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)), + p("sizeVariation", "Variation % (Size)", num(50.0), pct()), + p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("speed", "Speed", num(100.0), slider(0.0, 5000.0, 0.0, 1000.0, 1)), + p("speedVariation", "Variation % (Speed)", num(50.0), pct()), + p("wind", "Wind", num(25.0), slider(-5000.0, 5000.0, -500.0, 500.0, 1)), + p("windVariation", "Variation % (Wind)", num(50.0), pct()), + p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("wiggleAmount", "Amount (Wiggle)", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("wiggleAmountVariation", "Variation % (Amount)", num(50.0), pct()), + p("wiggleFrequency", "Frequency (Wiggle)", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("wiggleFrequencyVariation", "Variation % (Frequency)", num(50.0), pct()), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("transferMode", "Transfer Mode", Value::Enum(0), transfer()), + p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), + p("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + snowfall, + ), + spec( + "ec.sim.ccbubbles", + "CC Bubbles", + vec![ + p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("wobbleAmplitude", "Wobble Amplitude", num(5.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("wobbleFrequency", "Wobble Frequency", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), + p("bubbleSize", "Bubble Size", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("reflectionType", "Reflection Type", Value::Enum(0), popup(&["Liquid", "Metal"])), + p("shadingType", "Shading Type", Value::Enum(0), popup(&["None", "Lighten", "Darken", "Fade Inwards", "Fade Outwards"])), + ], + bubbles, + ), + spec( + "ec.sim.ccdrizzle", + "CC Drizzle", + vec![ + p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)), + p("rippling", "Rippling", num(360.0), ParamUi::Angle), + p("displacement", "Displacement", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("rippleHeight", "Ripple Height", num(30.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("spreading", "Spreading", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightHeight", "Light Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("diffuse", "Diffuse", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), + ], + drizzle, + ), + spec( + "ec.sim.ccstarburst", + "CC Star Burst", + vec![ + p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)), + p("phase", "Phase", num(0.0), ParamUi::Angle), + p("gridSpacing", "Grid Spacing", num(2.0), slider(1.0, 100.0, 1.0, 20.0, 0)), + p("size", "Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("blendWithOriginal", "Blend w. Original", num(0.0), pct()), + ], + star_burst, + ), + spec( + "ec.sim.ccparticleworld", + "CC Particle World", + vec![ + p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("producerX", "Position X", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producerY", "Position Y", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producerZ", "Position Z", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("radiusX", "Radius X", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("radiusY", "Radius Y", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("radiusZ", "Radius Z", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("velocity", "Velocity", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 2)), + p("inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("gravity", "Gravity", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 3)), + p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("extraAngle", "Extra Angle", num(360.0), ParamUi::Angle), + p("axisX", "Direction Axis X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("axisY", "Direction Axis Y", num(-1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("axisZ", "Direction Axis Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityX", "Gravity Vector X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityY", "Gravity Vector Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityZ", "Gravity Vector Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p("birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("sizeVariation", "Size Variation", num(0.0), pct()), + p("maxOpacity", "Max Opacity", num(75.0), pct()), + p("birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + particle_world, + ), + spec( + "ec.sim.ccparticlesystems2", + "CC Particle Systems II", + vec![ + p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("radiusX", "Radius X", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("radiusY", "Radius Y", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("velocity", "Velocity", num(2.0), slider(-20.0, 20.0, 0.0, 10.0, 2)), + p("inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p("birthSize", "Birth Size", num(0.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("deathSize", "Death Size", num(1.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("sizeVariation", "Size Variation", num(0.0), pct()), + p("maxOpacity", "Max Opacity", num(75.0), pct()), + p("birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + particle_systems2, + ), + spec( + "ec.sim.ccmrmercury", + "CC Mr. Mercury", + vec![ + p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("producer", "Producer", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("velocity", "Velocity", num(1.0), slider(-20.0, 20.0, 0.0, 5.0, 2)), + p("birthRate", "Birth Rate", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("longevity", "Longevity (sec)", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("gravity", "Gravity", num(0.5), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("blobInfluence", "Blob Influence", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("blobBirthSize", "Blob Birth Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("blobDeathSize", "Blob Death Size", num(0.25), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("diffuse", "Diffuse", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(50.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), + p("metal", "Metal", num(100.0), pct()), + ], + mr_mercury, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn grey(w: u32, hh: u32) -> Image { + Image::filled(w, hh, [0.2, 0.2, 0.2, 1.0]) + } + + fn ramp(w: u32, hh: u32) -> Image { + let mut img = Image::new(w, hh); + for y in 0..hh { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { + run_fx(id, vals, img, t, EffectEnv::default()).img + } + + fn diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() + } + + /// Centre of mass (luminance-weighted difference from `base`) in y. + fn mass_y(img: &Image, base: &Image) -> f32 { + let (mut m, mut sy) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let i = img.idx(x, y); + let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum(); + m += d; + sy += d * y as f32; + } + } + if m > 0.0 { sy / m } else { -1.0 } + } + + #[test] + fn rasteriser_over_and_add() { + let s = [Sprite::new(5.0, 5.0, 2.0, [1.0, 0.0, 0.0, 1.0], Shape::Disc)]; + let img = splat(10, 40, &s, Acc::Over); + assert!(img.get(5, 5)[0] > 0.99 && img.get(9, 30)[3] == 0.0); + let two = [s[0], s[0]]; + let add = splat(10, 40, &two, Acc::Add); + assert!(add.get(5, 5)[0] > 1.9); + // Items spanning band boundaries are drawn in every band. + let tall = [Sprite::new(5.0, 16.0, 6.0, [0.0, 1.0, 0.0, 1.0], Shape::Square)]; + let t = splat(10, 40, &tall, Acc::Over); + assert!(t.get(5, 13)[1] > 0.9 && t.get(5, 18)[1] > 0.9); + } + + #[test] + fn rainfall_deterministic_and_falls() { + let vals = [("drops", num(200.0)), ("opacity", num(100.0)), ("appearance", Value::Enum(1)), ("color", col(1.0, 1.0, 1.0))]; + let a = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); + let b = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); + assert_eq!(a, b); + assert!(diff(&a, &grey(40, 40)) > 1.0, "drops visible"); + let none = run("ec.sim.ccrainfall", &[("drops", num(0.0))], grey(40, 40), 0.5); + assert_eq!(none, grey(40, 40)); + let later = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.52); + assert!(diff(&a, &later) > 0.1, "rain moves"); + } + + #[test] + fn snowfall_deterministic_and_moves() { + let vals = [("flakes", num(300.0))]; + let a = run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0); + assert_eq!(a, run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0)); + assert!(diff(&a, &grey(40, 40)) > 1.0); + assert!(diff(&a, &run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.2)) > 0.1); + let off = run("ec.sim.ccsnowfall", &[("flakes", num(300.0)), ("compositeWithOriginal", Value::Bool(false))], grey(40, 40), 1.0); + assert!(off.data.iter().any(|p| p[3] == 0.0), "without original the background is transparent"); + } + + #[test] + fn bubbles_rise() { + let vals = [("bubbleAmount", num(30.0)), ("bubbleSize", num(3.0))]; + let a = run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3); + assert_eq!(a, run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3)); + assert!(a.data.iter().any(|p| p[3] > 0.5)); + let none = run("ec.sim.ccbubbles", &[("bubbleAmount", num(0.0))], ramp(40, 40), 0.3); + assert!(none.data.iter().all(|p| p[3] == 0.0), "bubbles replace the layer"); + // A single slow bubble moves up over a short time. + let one = [("bubbleAmount", num(1.0)), ("bubbleSize", num(4.0)), ("wobbleAmplitude", num(0.0)), ("bubbleSpeed", num(0.2))]; + let blank = Image::new(40, 40); + let y0 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.0), &blank); + let y1 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.1), &blank); + assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); + } + + #[test] + fn drizzle_ripples() { + let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); + assert_eq!(a, run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6)); + assert!(diff(&a, &ramp(48, 48)) > 0.1); + let none = run("ec.sim.ccdrizzle", &[("dripRate", num(0.0))], ramp(48, 48), 0.6); + assert_eq!(none, ramp(48, 48)); + } + + #[test] + fn star_burst_moves_stars() { + let a = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5); + assert_eq!(a, run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5)); + assert!(a.data.iter().any(|p| p[3] > 0.0)); + let b = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.9); + assert!(diff(&a, &b) > 0.5); + let full = run("ec.sim.ccstarburst", &[("blendWithOriginal", num(100.0))], ramp(40, 40), 0.5); + assert!(diff(&full, &ramp(40, 40)) < 1e-3); + } + + fn pw_vals(seed: f64) -> Vec<(&'static str, Value)> { + vec![("birthRate", num(1.0)), ("randomSeed", num(seed)), ("particleType", Value::Enum(2)), ("birthSize", num(0.3)), ("deathSize", num(0.3))] + } + + #[test] + fn particle_world_seek_consistent() { + let v = pw_vals(11.0); + let direct = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 2.0); + let v2 = pw_vals(12.0); + let _ = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 1.0); + let stepped = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); + let direct2 = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); + assert_eq!(stepped, direct2); + assert!(direct.data.iter().any(|p| p[3] > 0.0), "particles alive"); + // Before birth nothing is drawn. + let t0 = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 0.0); + assert!(t0.data.iter().all(|p| p[3] == 0.0)); + } + + #[test] + fn engine_resume_equals_from_scratch() { + for anim in 0..PW_ANIMS.len() as u32 { + let phys = Phys { + rate: 90.0, + life: 1.5, + producer: [0.0; 3], + radius: [0.05; 3], + anim, + speed: 0.5, + gravity: [0.0, 0.5, 0.0], + resistance: 0.2, + axis: [0.0, -1.0, 0.0], + extra: 1.0, + extra_angle: 60.0, + seed: 9, + max: 10_000, + flat: anim % 2 == 0, + }; + let a: SimCache = SimCache::new(2); + let b: SimCache = SimCache::new(2); + let direct = simulate(&a, 1, 2.0, &phys); + let _ = simulate(&b, 1, 1.0, &phys); + let _ = simulate(&b, 1, 0.37, &phys); + let resumed = simulate(&b, 1, 2.0, &phys); + assert_eq!(direct.parts.len(), resumed.parts.len(), "anim {anim}"); + assert!(direct.parts.iter().zip(&resumed.parts).all(|(p, q)| p.p == q.p && p.v == q.v && p.id == q.id), "anim {anim}"); + assert!(!direct.parts.is_empty()); + } + } + + #[test] + fn particle_world_gravity_pulls_down() { + let base = |g: f64| { + vec![ + ("birthRate", num(2.0)), + ("velocity", num(0.2)), + ("gravity", num(g)), + ("particleType", Value::Enum(2)), + ("birthSize", num(0.2)), + ("deathSize", num(0.2)), + ("longevity", num(3.0)), + ] + }; + let blank = Image::new(48, 48); + let up = mass_y(&run("ec.sim.ccparticleworld", &base(0.0), blank.clone(), 1.0), &blank); + let down = mass_y(&run("ec.sim.ccparticleworld", &base(2.0), blank.clone(), 1.0), &blank); + assert!(down > up + 2.0, "{up} {down}"); + } + + #[test] + fn particle_systems2_seek_and_direction() { + let v = vec![("position", pt(24.0, 24.0)), ("birthRate", num(3.0)), ("gravity", num(0.0)), ("velocity", num(1.0)), ("animation", Value::Enum(1))]; + let _ = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 0.5); + let a = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 1.0); + let mut v2 = v.clone(); + v2.push(("randomSeed", num(0.0))); + assert_eq!(a, run("ec.sim.ccparticlesystems2", &v2, Image::new(48, 48), 1.0)); + // Direction 0° (up): particles above the producer. + let blank = Image::new(48, 48); + let y = mass_y(&a, &blank); + assert!((0.0..24.0).contains(&y), "{y}"); + let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0); + assert_eq!(none, grey(20, 20)); + } + + #[test] + fn mercury_blobs_and_seek() { + let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))]; + let _ = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 0.7); + let a = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 1.4); + let mut v2 = v.clone(); + v2.push(("metal", num(100.0))); + let b = run("ec.sim.ccmrmercury", &v2, ramp(48, 48), 1.4); + assert_eq!(a, b); + assert!(a.data.iter().any(|p| p[3] > 0.5), "blobs visible"); + let none = run("ec.sim.ccmrmercury", &[("birthRate", num(0.0))], ramp(48, 48), 1.4); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + } + + #[test] + fn half_resolution_scales_sprites() { + // Downsampled buffers render the same layout at half size. + let s = crate::find("ec.sim.ccsnowfall").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("flakes".into(), num(50.0)); + params.values.insert("offset".into(), pt(20.0, 20.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let full = crate::apply(s, &ctx, Buf { img: grey(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: grey(20, 20), offset: [0.0; 2], scale: 0.5 }); + let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; + assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); + } } diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index d8d6ce96a..133dc87e7 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1,7 +1,1610 @@ -//! Simulation effects, part 2. +//! Simulation effects, part 2: layer-shattering and surface simulations — CC Ball Action, +//! CC Pixel Polly, CC Scatterize, Card Dance, Shatter (planar pieces in perspective), Caustics, +//! Wave World (stepped 2D wave equation) and Foam (stepped bubble sim). +//! +//! Written from the public descriptions of the effects' behaviour. Pieces are textured convex +//! polygons placed in 3D and drawn through a per-piece inverse homography, far to near. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::sim::{Acc, Shape, Sprite, h, hs, pct, pt, raster, spec, splat}; +use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +// ------------------------------------------------------------------ 3D pieces + +type M3 = [[f64; 3]; 3]; + +fn mat_mul(a: &M3, b: &M3) -> M3 { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) +} + +/// Rotation from Euler angles (radians), applied X then Y then Z. +fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + let (sz, cz) = z.sin_cos(); + let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; + let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; + let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; + mat_mul(&rz, &mat_mul(&ry, &rx)) +} + +/// Rotation by `angle` about unit `axis` (Rodrigues). +fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { + let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); + if l < 1e-12 || angle == 0.0 { + return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + } + let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l); + let (s, c) = angle.sin_cos(); + let t = 1.0 - c; + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] +} + +fn inv3(m: &M3) -> Option { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + if det.abs() < 1e-12 { + return None; + } + let d = 1.0 / det; + Some([ + [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], + [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], + [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], + ]) +} + +/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on +/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z). +#[derive(Clone, Copy)] +struct Cam { + c: [f64; 2], + d: f64, +} + +/// A planar textured piece ready to draw. +#[derive(Clone, Copy)] +struct Piece { + /// Screen (buffer px, homogeneous) → piece-local (a, b, w). + inv: M3, + /// Rest centre of the piece in texture pixels. + c0: [f32; 2], + /// Convex polygon (texture pixels), counter-clockwise in y-down space. + poly: [[f32; 2]; 6], + n: u8, + bbox: [f32; 4], + shade: f32, + alpha: f32, + /// Flat straight colour instead of the texture. + flat: Option<[f32; 3]>, + /// Facing away from the camera (draw the back texture). + back: bool, + depth: f64, +} + +impl Piece { + /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its + /// centre at 3D `pos` (buffer px; z away from the camera). + fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: Cam) -> Option { + let n = poly.len().min(6); + if n < 3 { + return None; + } + let (cx, cy, d) = (cam.c[0], cam.c[1], cam.d); + let zc = pos[2]; + let hm: M3 = [ + [cx * r[2][0] + d * r[0][0], cx * r[2][1] + d * r[0][1], cx * (d + zc) + d * (pos[0] - cx)], + [cy * r[2][0] + d * r[1][0], cy * r[2][1] + d * r[1][1], cy * (d + zc) + d * (pos[1] - cy)], + [r[2][0], r[2][1], d + zc], + ]; + let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for v in &poly[..n] { + let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); + let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; + if w < d * 0.05 { + return None; + } + let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; + let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32; + bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)]; + } + let inv = inv3(&hm)?; + let mut pp = [[0.0f32; 2]; 6]; + pp[..n].copy_from_slice(&poly[..n]); + // Normalise winding so "inside" is cross >= 0 for every edge. + let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum(); + if area < 0.0 { + pp[..n].reverse(); + } + // Front normal (0, 0, -1) rotated; facing the camera when its z is negative. + let nz = -r[2][2]; + Some(Piece { + inv, + c0, + poly: pp, + n: n as u8, + bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], + shade: 1.0, + alpha: 1.0, + flat: None, + back: nz > 0.0, + depth: zc, + }) + } + + /// Lambert factor of the piece normal against a light shining along +z. + fn facing(r: &M3) -> f32 { + r[2][2].abs() as f32 + } +} + +/// Draw pieces (already sorted far → near) over `out`. +fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { + raster( + out, + pieces, + |q| Some(q.bbox), + |q, x, y| { + let (xf, yf) = (x as f64, y as f64); + let m = &q.inv; + let w = m[2][0] * xf + m[2][1] * yf + m[2][2]; + if w.abs() < 1e-12 { + return None; + } + let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0]; + let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1]; + let n = q.n as usize; + let mut md = f32::INFINITY; + for i in 0..n { + let a = q.poly[i]; + let b = q.poly[(i + 1) % n]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l = (ex * ex + ey * ey).sqrt().max(1e-6); + md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l); + } + // Coverage extends half a pixel past the edge so abutting pieces leave no seams. + let cov = (md + 1.0).clamp(0.0, 1.0); + if cov <= 0.0 { + return None; + } + let (c, a) = match q.flat { + Some(c) => (c, 1.0), + None => { + let tex = if q.back { back.unwrap_or(front) } else { front }; + unpremul(tex.sample_bilinear(u as f64, v as f64)) + } + }; + let a = a * cov * q.alpha; + if a <= 1e-6 { + return None; + } + let k = q.shade; + Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a]) + }, + Acc::Over, + ); +} + +fn sort_far_first(pieces: &mut [Piece]) { + pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); +} + +// ------------------------------------------------------------------ CC Ball Action + +fn ball_action(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let axis = pr.e("rotationAxis"); + let rot = pr.f("rotation").to_radians(); + let twist_prop = pr.e("twistProperty"); + let twist = pr.f("twistAngle").to_radians(); + let spacing = pr.f("gridSpacing").max(1.0) as f32; + let size = pr.f("ballSize") as f32 / 100.0; + let instab = pr.f("instabilityState").to_radians() as f32; + let seed = ctx.seed.wrapping_mul(0x2545f491); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let nx = (lw / spacing).ceil().max(1.0) as u32; + let ny = (lh / spacing).ceil().max(1.0) as u32; + let d = 2.0 * lw.max(lh) as f64; + let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5); + let src = b.img.clone(); + let ax: [f64; 3] = match axis { + 0 => [1.0, 0.0, 0.0], + 1 => [0.0, 1.0, 0.0], + 2 => [0.0, 0.0, 1.0], + 3 => [1.0, 1.0, 0.0], + 4 => [1.0, 0.0, 1.0], + 5 => [0.0, 1.0, 1.0], + _ => [1.0, 1.0, 1.0], + }; + let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0); + let mut balls: Vec<(f64, Sprite)> = (0..nx * ny) + .into_par_iter() + .filter_map(|i| { + let gx = (i % nx) as f32 * spacing + spacing * 0.5; + let gy = (i / nx) as f32 * spacing + spacing * 0.5; + let (sx, sy) = b.to_px([gx as f64, gy as f64]); + let (c, a) = unpremul(src.sample_bilinear(sx, sy)); + if a <= 0.0 || size <= 0.0 { + return None; + } + let tp = match twist_prop { + 0 => gx / lw.max(1.0), + 1 => gy / lh.max(1.0), + 2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr, + _ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::() / 3.0, + } as f64; + let ang = rot + twist * tp; + // Scatter: random 3D offsets whose direction drifts with the instability state. + let ph = h(i, 1, seed) * std::f32::consts::TAU + instab; + let sc = scatter * h(i, 2, seed); + let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64]; + let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]]; + let m = rot_axis(ax, ang); + let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]); + if d + q[2] <= d * 0.05 { + return None; + } + let persp = d / (d + q[2]); + let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]); + let r = spacing * 0.5 * size * persp as f32 * s; + Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere))) + }) + .collect(); + balls.sort_by(|a, b| b.0.total_cmp(&a.0)); + let sprites: Vec = balls.into_iter().map(|(_, s)| s).collect(); + b.img = splat(src.width, src.height, &sprites, Acc::Over); + b +} + +// ------------------------------------------------------------------ CC Pixel Polly + +fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let t = ctx.time - pr.f("startTime"); + if t <= 0.0 { + return b; + } + let force = pr.f("force"); + let gravity = pr.f("gravity"); + let spin = pr.f("spinning").to_radians(); + let fc = pr.v2("forceCenter"); + let dir_rand = pr.f("directionRandomness") / 100.0; + let spd_rand = pr.f("speedRandomness") / 100.0; + let g = pr.f("gridSpacing").max(1.0); + let object = pr.e("object"); + let depth_sort = pr.b("enableDepthSort"); + let seed = ctx.seed.wrapping_mul(0x632be5ab); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let nx = (lw / g).ceil().max(1.0) as u32; + let ny = (lh / g).ceil().max(1.0) as u32; + let tri = object <= 1; + let textured = object == 1 || object == 3; + let maxd = lw.max(lh); + let src = b.img.clone(); + let per = if tri { 2 } else { 1 }; + let mut pieces: Vec = (0..nx * ny * per) + .into_par_iter() + .filter_map(|i| { + let cell = i / per; + let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g); + let poly: Vec<[f64; 2]> = if !tri { + vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + } else if i % 2 == 0 { + vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]] + } else { + vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + }; + let c = [poly.iter().map(|v| v[0]).sum::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / poly.len() as f64]; + let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]); + let dist = (dx * dx + dy * dy).sqrt().max(1e-6); + let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64; + let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64); + let vz = -speed * 0.5 * h(i, 3, seed) as f64; + let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t]; + let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64]; + let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin)); + let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; + pc.shade = 0.35 + 0.65 * Piece::facing(&r); + if !textured { + let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64)); + if a <= 0.0 { + return None; + } + pc.flat = Some(cc); + pc.alpha = a; + } + Some(pc) + }) + .collect(); + if depth_sort { + sort_far_first(&mut pieces); + } + let mut out = Image::new(src.width, src.height); + draw_pieces(&mut out, &pieces, &src, None); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Scatterize + +fn scatterize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let right = pr.f("rightTwist").to_radians(); + let left = pr.f("leftTwist").to_radians(); + if scatter == 0.0 && right == 0.0 && left == 0.0 { + return b; + } + let add = pr.e("transferMode") == 1; + let seed = ctx.seed.wrapping_mul(0x7feb352d); + let s = b.scale; + let (w, hh) = (b.img.width, b.img.height); + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let half = (ctx.layer_size[0] * 0.5 * s).max(1.0); + let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s; + let src = &b.img; + let sprites: Vec = (0..w * hh) + .into_par_iter() + .filter_map(|i| { + let px = src.data[i as usize]; + if px[3] <= 0.0 { + return None; + } + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + // Twist about the horizontal axis through the centre, growing towards each side. + let k = ((x - cx) / half).abs().min(1.0); + let ang = if x < cx { left } else { right } * k; + let (sa, ca) = ang.sin_cos(); + let (yy, zz) = ((y - cy) * ca, (y - cy) * sa); + let persp = d / (d + zz).max(d * 0.05); + let jx = hs(i, 1, seed) * scatter * s as f32; + let jy = hs(i, 2, seed) * scatter * s as f32; + let sx = cx + (x - cx) * persp + jx as f64; + let sy = cy + yy * persp + jy as f64; + let (c, a) = unpremul(px); + Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc)) + }) + .collect(); + b.img = splat(w, hh, &sprites, if add { Acc::Add } else { Acc::Over }); + b +} + +// ------------------------------------------------------------------ Card Dance + +const CD_SOURCES: &[&str] = &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"]; +const CD_PROPS: [(&str, &str); 8] = [ + ("xPos", "X Position"), + ("yPos", "Y Position"), + ("zPos", "Z Position"), + ("xRot", "X Rotation"), + ("yRot", "Y Rotation"), + ("zRot", "Z Rotation"), + ("xScale", "X Scale"), + ("yScale", "Y Scale"), +]; + +fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { + let (c1, a1) = unpremul(g1); + let (c2, a2) = unpremul(g2); + let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]); + match src { + 1 => lum(c1), + 2 => c1[0], + 3 => c1[1], + 4 => c1[2], + 5 => a1, + 6 => lum(c2), + 7 => c2[0], + 8 => c2[1], + 9 => c2[2], + 10 => a2, + _ => 0.0, + } +} + +fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; + let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; + let g1 = layer_or_self(ctx, &b, "gradientLayer1", true, true); + let g2 = layer_or_self(ctx, &b, "gradientLayer2", true, true); + let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let (cw, ch) = (lw / cols as f64, lh / rows as f64); + let zpos = pr.f("cameraZ").max(0.05); + let focal = pr.f("focalLength").max(1.0); + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: lw.max(lh) * s * zpos * focal / 140.0 }; + let ambient = pr.f("ambientLight") as f32; + let diffuse = pr.f("diffuse") as f32; + let intensity = pr.f("lightIntensity") as f32; + let props: Vec<(u32, f64, f64)> = + CD_PROPS.iter().map(|(id, _)| (pr.e(&format!("{id}Source")), pr.f(&format!("{id}Multiplier")), pr.f(&format!("{id}Offset")))).collect(); + let src = b.img.clone(); + let mut pieces: Vec = (0..rows * cols) + .into_par_iter() + .filter_map(|i| { + let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch); + let c = [cx0 + cw * 0.5, cy0 + ch * 0.5]; + let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]); + let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by)); + let val = |k: usize| { + let (sr, m, o) = props[k]; + o + m * card_source(sr, gp1, gp2) as f64 + }; + let (sx, sy) = (val(6), val(7)); + let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians()); + let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]); + let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s]; + let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32); + let (tw, th) = ((cw * s) as f32, (ch * s) as f32); + let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; + let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; + pc.shade = ambient + diffuse * intensity * Piece::facing(&r); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = Image::new(src.width, src.height); + draw_pieces(&mut out, &pieces, &src, back.as_ref()); + b.img = out; + b +} + +// ------------------------------------------------------------------ Shatter + +const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"]; + +/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. +fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { + let mut out = Vec::new(); + let i0 = (x0 / cell).floor() as i64 - 1; + let i1 = (x1 / cell).ceil() as i64 + 1; + let j0 = (y0 / cell).floor() as i64 - 1; + let j1 = (y1 / cell).ceil() as i64 + 1; + match kind { + 0 => { + let bh = cell * 0.5; + let r0 = (y0 / bh).floor() as i64 - 1; + let r1 = (y1 / bh).ceil() as i64 + 1; + for r in r0..=r1 { + let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 }; + for i in i0..=i1 { + let (x, y) = (i as f64 * cell + sh, r as f64 * bh); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]); + } + } + } + 1 => { + // Jittered lattice split into triangles along a random diagonal. + let jit = |i: i64, j: i64| -> [f64; 2] { + let (a, b) = (i as u32, j as u32); + [i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35] + }; + for j in j0..=j1 { + for i in i0..=i1 { + let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1)); + if h(i as u32, j as u32, seed ^ 0x55) < 0.5 { + out.push(vec![p00, p10, p11]); + out.push(vec![p00, p11, p01]); + } else { + out.push(vec![p00, p10, p01]); + out.push(vec![p10, p11, p01]); + } + } + } + } + 2 => { + let r = cell / 3f64.sqrt(); + let dx = 1.5 * r; + let dy = 3f64.sqrt() * r; + let c0 = (x0 / dx).floor() as i64 - 1; + let c1 = (x1 / dx).ceil() as i64 + 1; + let rr0 = (y0 / dy).floor() as i64 - 1; + let rr1 = (y1 / dy).ceil() as i64 + 1; + for ci in c0..=c1 { + for rj in rr0..=rr1 { + let cx = ci as f64 * dx; + let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy; + out.push( + (0..6) + .map(|k| { + let a = k as f64 * std::f64::consts::PI / 3.0; + [cx + r * a.cos(), cy + r * a.sin()] + }) + .collect(), + ); + } + } + } + 3 => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + _ => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + if (i + j).rem_euclid(2) == 0 { + out.push(vec![[x, y], [x + cell, y], [x, y + cell]]); + out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } else { + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]); + out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + } + } + out +} + +/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors). +fn drag_factors(k: f64, t: f64) -> (f64, f64) { + if k < 1e-6 { + (t, 0.5 * t * t) + } else { + let e = (-k * t).exp(); + ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) + } +} + +fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let view = pr.e("view"); + let render = pr.e("render"); + let kind = pr.e("pattern"); + let reps = pr.f("repetitions").clamp(1.0, 500.0); + let dir = pr.f("direction").to_radians(); + let origin = pr.v2("origin"); + let forces = [ + (pr.v2("force1Position"), pr.f("force1Depth"), pr.f("force1Radius"), pr.f("force1Strength")), + (pr.v2("force2Position"), pr.f("force2Depth"), pr.f("force2Radius"), pr.f("force2Strength")), + ]; + let rot_speed = pr.f("rotationSpeed"); + let tumble = pr.e("tumbleAxis"); + let randomness = pr.f("randomness"); + let k = pr.f("viscosity").max(0.0) * 5.0; + let mass_var = pr.f("massVariance") / 100.0; + let gravity = pr.f("gravity"); + let gdir = pr.f("gravityDirection").to_radians(); + let ginc = pr.f("gravityInclination").to_radians(); + let ambient = pr.f("ambientLight") as f32; + let intensity = pr.f("lightIntensity") as f32; + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let t = ctx.time.max(0.0); + let unit = lw * 0.1; + let cell = lw / reps; + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let (sd, cd) = dir.sin_cos(); + let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; + // Pattern-space bounds of the layer rectangle. + let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| { + let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]); + [dx * cd + dy * sd, -dx * sd + dy * cd] + }); + let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); + let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); + let cells = pattern_cells(kind, cell, px0, py0, px1, py1, seed); + let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; + let src = b.img.clone(); + let mut pieces: Vec = cells + .par_iter() + .enumerate() + .filter_map(|(i, poly)| { + let i = i as u32; + let lp: Vec<[f64; 2]> = poly.iter().map(|&q| to_layer(q)).collect(); + let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); + let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); + if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh { + return None; + } + let c = [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + // Broken when inside a force sphere (centred `depth` in front of the layer). + let mut v = [0.0f64; 3]; + let mut broken = false; + for (fp, fd, fr, fs) in forces { + let rad = fr * lw; + if rad <= 0.0 { + continue; + } + let dv = [c[0] - fp[0], c[1] - fp[1], fd * lw]; + let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); + if dist < rad { + broken = true; + let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6); + for q in 0..3 { + v[q] += dv[q] * f; + } + } + } + if (broken && render == 1) || (!broken && render == 2) { + return None; + } + let mut pos = [c[0], c[1], 0.0]; + let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); + if broken && t > 0.0 { + let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; + for q in 0..3 { + v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; + } + let (f1, f2) = drag_factors(k, t); + for q in 0..3 { + pos[q] += v[q] * f1 + g3[q] * f2; + } + let axis = match tumble { + 1 => [0.0, 0.0, 0.0], + 2 => [1.0, 0.0, 0.0], + 3 => [0.0, 1.0, 0.0], + 4 => [0.0, 0.0, 1.0], + 5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0], + 6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64], + 7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + _ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + }; + let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit; + let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); + r = rot_axis(axis, omega * f1); + } + let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; + pc.shade = ambient + (1.0 - ambient) * intensity * Piece::facing(&r); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = Image::new(src.width, src.height); + if view == 0 { + draw_pieces(&mut out, &pieces, &src, None); + } else { + // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. + let animated = view == 2 || view == 4; + let mut lines = Vec::new(); + for pc in &pieces { + let n = pc.n as usize; + let pts: Vec<[f32; 2]> = if animated { + let fw = inv3(&pc.inv); + match fw { + Some(m) => pc.poly[..n] + .iter() + .map(|v| { + let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64); + let w = m[2][0] * a + m[2][1] * bb + m[2][2]; + [((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32] + }) + .collect(), + None => continue, + } + } else { + pc.poly[..n].to_vec() + }; + for e in 0..n { + let (a, c) = (pts[e], pts[(e + 1) % n]); + lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + if view >= 3 { + for (fp, _, fr, _) in forces { + let (fx, fy) = b.to_px(fp); + let rad = fr * lw * s; + for k in 0..64 { + let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU); + let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin()); + let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin()); + lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 })); + } + } + } + out = splat(src.width, src.height, &lines, Acc::Over); + } + b.img = out; + b +} + +// ------------------------------------------------------------------ Caustics + +/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected. +fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { + let (w, hh) = (img.width as f64, img.height as f64); + match mode { + 1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)), + 2 => { + let m = |v: f64, n: f64| { + let r = v.rem_euclid(2.0 * n); + if r > n { 2.0 * n - r } else { r } + }; + img.sample_bilinear_clamped(m(x, w), m(y, hh)) + } + _ => img.sample_bilinear(x, y), + } +} + +fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let s = b.scale; + let mut bottom = layer_or_self(ctx, &b, "bottom", true, pr.e("bottomSizeDiffers") == 1); + let blur = pr.f("blur") * s; + if blur > 0.05 { + bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false); + } + let scaling = pr.f("scaling").max(0.01); + let repeat = pr.e("repeatMode"); + let wave_h = pr.f("waveHeight"); + let smoothing = pr.f("smoothing") * s; + let depth = pr.f("waterDepth"); + let ior = pr.f("refractiveIndex").max(1.0); + let surf = pr.color("surfaceColor"); + let surf_op = pr.f("surfaceOpacity") as f32; + let cstr = pr.f("causticsStrength") as f32; + let li = pr.f("lightIntensity") as f32; + let lc = pr.color("lightColor"); + let lpos = pr.v2("lightPosition"); + let lheight = pr.f("lightHeight").max(0.01); + let ambient = pr.f("ambientLight") as f32; + let diffuse = pr.f("diffuse") as f32; + let specular = pr.f("specular") as f32; + let sharp = pr.f("highlightSharpness").max(1.0) as f32; + let (w, hh) = (b.img.width as usize, b.img.height as usize); + // Height field of the water surface (luminance of the chosen layer; none = flat). + let height = ctx.layer_param("waterSurface", true).map(|o| { + let img = crate::util::fit_layer(ctx, &b, &o, true); + let pl = Plane::luma(&img); + if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } + }); + let lw = ctx.layer_size[0] * s; + let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let (lx, ly) = b.to_px(lpos); + let lv = { + let v = [(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] + }; + let grad = |x: usize, y: usize| -> (f64, f64) { + match &height { + Some(pl) => { + let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; + let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; + (gx, gy) + } + None => (0.0, 0.0), + } + }; + // Displacement and its Jacobian (caustic focusing) per pixel. + let disp: Vec<(f64, f64)> = (0..w * hh) + .into_par_iter() + .map(|i| { + let (gx, gy) = grad(i % w, i / w); + (-gx * k, -gy * k) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let i = y * w + x; + let (dx, dy) = disp[i]; + let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)]; + let (dxr, _) = d(x + 1, y); + let (_, dyd) = d(x, y + 1); + let (dxl, _) = d(x.saturating_sub(1), y); + let (_, dyu) = d(x, y.saturating_sub(1)); + let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); + let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0); + let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling; + let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling; + let p = sample_repeat(&bottom, sx, sy, repeat); + let (c, a) = unpremul(p); + let (gx, gy) = grad(x, y); + let n = { + let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); + [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] + }; + let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); + let hv = { + let v = [lv[0], lv[1], lv[2] + 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; + let light = ambient + diffuse * li * nl + cstr * focus * li; + let cc: [f32; 3] = std::array::from_fn(|q| { + let base = c[q] * light * lc[q]; + base * (1.0 - surf_op) + surf[q] * surf_op * (ambient + diffuse * li * nl) + spec * lc[q] + }); + let a = (a + surf_op * (1.0 - a)).min(1.0); + *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; + } + }); + b +} + +// ------------------------------------------------------------------ Wave World + +#[derive(Clone, Debug)] +struct Waves { + nx: usize, + ny: usize, + u: Vec, + v: Vec, +} + +struct Producer { + ring: bool, + pos: [f32; 2], + len: f32, + width: f32, + angle: f32, + amp: f32, + freq: f32, + phase: f32, +} + +impl Producer { + /// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths). + fn weight(&self, x: f32, y: f32) -> f32 { + let (dx, dy) = (x - self.pos[0], y - self.pos[1]); + let (sa, ca) = self.angle.sin_cos(); + let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca); + let d = if self.ring { + let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3)); + ((u / a).powi(2) + (v / bb).powi(2)).sqrt() + } else { + let l = (self.len * 0.5).max(1e-3); + let wd = (self.width * 0.5).max(1e-3); + (u.abs() / l).max(v.abs() / wd) + }; + (1.0 - d).clamp(0.0, 1.0).min(1.0) + } +} + +static WAVE_CACHE: SimCache = SimCache::new(4); + +fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let [lw, lh] = ctx.layer_size; + let res = pr.f("gridResolution").clamp(1.0, 400.0) as usize; + let res = if pr.b("gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res }; + let nx = res.max(2) + 1; + let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1; + let speed = pr.f("waveSpeed").max(0.0) as f32; + let damping = pr.f("damping").max(0.0) as f32; + let reflect = pr.e("reflectEdges"); + let preroll = pr.f("preRoll").max(0.0); + let prods: Vec = [1, 2] + .iter() + .map(|k| { + let pos = pr.v2(&format!("producer{k}Position")); + Producer { + ring: pr.e(&format!("producer{k}Type")) == 0, + pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32], + len: pr.f(&format!("producer{k}Length")) as f32, + width: pr.f(&format!("producer{k}Width")) as f32, + angle: (pr.f(&format!("producer{k}Angle")) as f32).to_radians(), + amp: pr.f(&format!("producer{k}Amplitude")) as f32, + freq: pr.f(&format!("producer{k}Frequency")) as f32, + phase: (pr.f(&format!("producer{k}Phase")) as f32).to_radians(), + } + }) + .collect(); + let sps = crate::sim::SPS as f32; + // Wave speed in layer widths per second → cells per second; sub-steps keep it stable. + let cps = speed * (nx - 1) as f32; + let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64); + let dt = 1.0 / (sps * sub as f32); + let cell = 1.0 / (nx - 1) as f32; + let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("gridResDownsamples") { b.scale } else { 1.0 } }, 4); + let steps = crate::sim::steps_at(ctx.time + preroll); + let st = WAVE_CACHE.run( + key, + steps, + || Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] }, + |w, step| { + for k in 0..sub { + let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32; + let c2 = cps * cps; + let mut acc = vec![0.0f32; nx * ny]; + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; + let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; + acc[i] = c2 * l - damping * 4.0 * w.v[i]; + } + } + for i in 0..nx * ny { + w.v[i] += acc[i] * dt; + w.u[i] += w.v[i] * dt; + } + // Producers drive the surface. + for (p, wt) in prods.iter().zip(&weights) { + if p.amp == 0.0 { + continue; + } + let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin(); + for i in 0..nx * ny { + if wt[i] > 0.0 { + w.u[i] += (target - w.u[i]) * wt[i]; + w.v[i] *= 1.0 - wt[i]; + } + } + } + // Edges: reflective ones mirror; the others absorb in a thin sponge layer. + let refl = |side: u32| reflect == 5 || reflect == side; + let sponge = 4usize.min(nx / 4).max(1); + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let mut f = 1.0f32; + if !refl(1) && x < sponge { + f = f.min(x as f32 / sponge as f32); + } + if !refl(3) && nx - 1 - x < sponge { + f = f.min((nx - 1 - x) as f32 / sponge as f32); + } + if !refl(2) && y < sponge { + f = f.min(y as f32 / sponge as f32); + } + if !refl(4) && ny - 1 - y < sponge { + f = f.min((ny - 1 - y) as f32 / sponge as f32); + } + if f < 1.0 { + let k = 0.85 + 0.15 * f; + w.u[i] *= k; + w.v[i] *= k; + } + } + } + } + }, + ); + let s = b.scale; + let (bw, bh) = (b.img.width, b.img.height); + let sample = |x: f64, y: f64| -> f32 { + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64); + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64); + let (x0, y0) = (gx.floor() as usize, gy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1)); + let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32); + let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx]; + let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx; + let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; + a + (c - a) * ty + }; + if pr.e("view") == 1 { + let bright = pr.f("brightness") as f32; + let contrast = pr.f("contrast") as f32; + let gamma = pr.f("gamma").max(0.01) as f32; + let alpha = 1.0 - pr.f("transparency") as f32; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) / s; + let ly = (y as f64 + 0.5 - b.offset[1]) / s; + let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); + *px = [v * alpha, v * alpha, v * alpha, alpha]; + } + }); + } else { + // Wireframe preview: the grid seen at an angle, heights lifting the lines. + let lift = lh as f32 * 0.25; + let to = |x: usize, y: usize| -> [f32; 2] { + let lx = x as f64 / (st.nx - 1) as f64 * lw; + let ly = y as f64 / (st.nx - 1) as f64 * lw; + let (px, py) = b.to_px([lx, lh * 0.25 + ly * 0.6]); + [px as f32, py as f32 - st.u[y * st.nx + x] * lift * s as f32] + }; + let mut lines = Vec::new(); + for y in 0..st.ny { + for x in 0..st.nx { + let a = to(x, y); + if x + 1 < st.nx { + let c = to(x + 1, y); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + if y + 1 < st.ny { + let c = to(x, y + 1); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + } + let wire = splat(bw, bh, &lines, Acc::Over); + b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0); + } + b +} + +// ------------------------------------------------------------------ Foam + +#[derive(Clone, Debug)] +struct FoamBubble { + p: [f32; 2], + v: [f32; 2], + age: f32, + size: f32, + id: u32, +} + +#[derive(Clone, Debug, Default)] +struct FoamState { + b: Vec, + carry: f32, + next: u32, +} + +static FOAM_CACHE: SimCache = SimCache::new(4); + +/// Foam steps once per frame at 30 frames per second. +const FOAM_FPS: f64 = 30.0; + +fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let unit = lw * 0.01; + let prod = pr.v2("producerPoint"); + let (psx, psy) = (pr.f("producerXSize") as f32 * lw, pr.f("producerYSize") as f32 * lh); + let porient = (pr.f("producerOrientation") as f32).to_radians(); + let rate = pr.f("productionRate").max(0.0) as f32; + let size = pr.f("size").max(0.0) as f32; + let size_var = pr.f("sizeVariance") as f32; + let lifespan = pr.f("lifespan").max(1.0) as f32; + let growth = pr.f("growthSpeed").max(0.0001) as f32; + let init_speed = pr.f("initialSpeed") as f32; + let init_dir = (pr.f("initialDirection") as f32).to_radians(); + let wind = pr.f("windSpeed") as f32; + let wind_dir = (pr.f("windDirection") as f32).to_radians(); + let turb = pr.f("turbulence") as f32; + let repulsion = pr.f("repulsion") as f32; + let viscosity = pr.f("viscosity").clamp(0.0, 1.0) as f32; + let sticky = pr.f("stickiness").clamp(0.0, 1.0) as f32; + let universe = pr.f("universeSize").max(0.01) as f32; + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; + let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; + let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { + let t = step as f32 / FOAM_FPS as f32; + // Births. + st.carry += rate; + let n = st.carry.floor() as u32; + st.carry -= n as f32; + for _ in 0..n { + if st.b.len() >= 3000 { + break; + } + let id = st.next; + st.next = st.next.wrapping_add(1); + let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy); + let (so, co) = porient.sin_cos(); + let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; + let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; + let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, id }); + } + // Forces: drift towards the wind, turbulence, viscosity. + for q in st.b.iter_mut() { + let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + for k in 0..2 { + q.v[k] += (wvec[k] - q.v[k]) * 0.1; + q.v[k] *= 1.0 - viscosity * 0.5; + } + q.v[0] += n1 * turb * unit; + q.v[1] += n2 * turb * unit; + } + // Repulsion between overlapping bubbles (grid hashed, deterministic order). + let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); + if repulsion > 0.0 && st.b.len() > 1 { + let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); + let cs = maxr * 2.0; + let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); + for (i, q) in st.b.iter().enumerate() { + grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); + } + let mut push = vec![[0.0f32; 2]; st.b.len()]; + for (i, q) in st.b.iter().enumerate() { + let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); + for dy in -1..=1 { + for dx in -1..=1 { + let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; + for &j in list { + if j <= i { + continue; + } + let o = &st.b[j]; + let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); + let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); + let overlap = rad(q) + rad(o) - d; + if overlap > 0.0 { + let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d; + push[i][0] -= ddx * f; + push[i][1] -= ddy * f; + push[j][0] += ddx * f; + push[j][1] += ddy * f; + } + } + } + } + } + for (q, d) in st.b.iter_mut().zip(&push) { + q.p[0] += d[0].clamp(-maxr, maxr); + q.p[1] += d[1].clamp(-maxr, maxr); + } + } + let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); + let (ux1, uy1) = (lw - ux0, lh - uy0); + for q in st.b.iter_mut() { + q.p[0] += q.v[0]; + q.p[1] += q.v[1]; + q.age += 1.0; + } + st.b.retain(|q| q.age < lifespan && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); + }); + let view = pr.e("view"); + let zoom = pr.f("zoom").max(0.01) as f32; + let texture = pr.e("bubbleTexture"); + let blend = pr.e("blendMode"); + let s = b.scale as f32; + let (cx, cy) = (lw * 0.5, lh * 0.5); + let mut order: Vec<&FoamBubble> = st.b.iter().collect(); + if blend == 2 { + // Solid New on Top: oldest first. + order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id))); + } else if blend == 1 { + order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); + } + let sprites: Vec = order + .iter() + .map(|q| { + let r = q.size * unit * 3.0 * (q.age * growth).min(1.0) * zoom * s; + let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); + let fade = ((lifespan - q.age) / 5.0).clamp(0.0, 1.0); + if view == 0 { + Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) + } else { + let (c, shape) = match texture { + 1 => ([0.95, 0.95, 0.92], Shape::Faded), + 2 => ([1.0, 0.6, 0.8], Shape::Bubble), + 3 => ([0.8, 0.85, 0.75], Shape::Bubble), + 4 => ([0.97, 0.97, 0.95], Shape::Sphere), + _ => ([1.0, 1.0, 1.0], Shape::Bubble), + }; + Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) + } + }) + .collect(); + b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view != 0 { Acc::Add } else { Acc::Over }); + b +} + +// ------------------------------------------------------------------ specs + +fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { + let ids: [&'static str; 8] = match k { + 1 => [ + "producer1Type", + "producer1Position", + "producer1Length", + "producer1Width", + "producer1Angle", + "producer1Amplitude", + "producer1Frequency", + "producer1Phase", + ], + _ => [ + "producer2Type", + "producer2Position", + "producer2Length", + "producer2Width", + "producer2Angle", + "producer2Amplitude", + "producer2Frequency", + "producer2Phase", + ], + }; + vec![ + p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])), + p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point), + p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[4], "Angle", num(0.0), ParamUi::Angle), + p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)), + p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)), + p(ids[7], "Phase", num(0.0), ParamUi::Angle), + ] +} + +fn card_dance_params() -> Vec { + let mut v = vec![ + p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follow Rows"])), + p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer), + p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer), + ]; + let names: [[&'static str; 4]; 8] = [ + ["xPosSource", "xPosMultiplier", "xPosOffset", "X Position"], + ["yPosSource", "yPosMultiplier", "yPosOffset", "Y Position"], + ["zPosSource", "zPosMultiplier", "zPosOffset", "Z Position"], + ["xRotSource", "xRotMultiplier", "xRotOffset", "X Rotation"], + ["yRotSource", "yRotMultiplier", "yRotOffset", "Y Rotation"], + ["zRotSource", "zRotMultiplier", "zRotOffset", "Z Rotation"], + ["xScaleSource", "xScaleMultiplier", "xScaleOffset", "X Scale"], + ["yScaleSource", "yScaleMultiplier", "yScaleOffset", "Y Scale"], + ]; + debug_assert_eq!(names.len(), CD_PROPS.len()); + for (i, n) in names.iter().enumerate() { + let rot = (3..6).contains(&i); + let scale = i >= 6; + let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) }; + v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES))); + v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + } + v.extend([ + p("cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)), + p("focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), + ]); + v +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.sim.ccballaction", + "CC Ball Action", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])), + p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle), + p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)), + p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("instabilityState", "Instability State", num(0.0), ParamUi::Angle), + ], + ball_action, + ), + spec( + "ec.sim.ccpixelpolly", + "CC Pixel Polly", + vec![ + p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), + p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("spinning", "Spinning", num(0.0), ParamUi::Angle), + p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point), + p("directionRandomness", "Direction Randomness", num(10.0), pct()), + p("speedRandomness", "Speed Randomness", num(10.0), pct()), + p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)), + p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])), + p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox), + p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)), + ], + pixel_polly, + ), + spec( + "ec.sim.ccscatterize", + "CC Scatterize", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), + p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])), + ], + scatterize, + ), + spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance), + spec( + "ec.sim.caustics", + "Caustics", + vec![ + p("bottom", "Bottom", Value::Layer(None), ParamUi::Layer), + p("scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer), + p("waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)), + p("surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), + p("lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), + p("ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + ], + caustics, + ), + spec( + "ec.sim.waveworld", + "Wave World", + { + let mut v = vec![ + p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), + p("brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), + p("contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), + p("transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), + p("gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), + p("waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), + p("preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), + ]; + v.extend(producer_params(1, 0.5, (0.5, 0.5))); + v.extend(producer_params(2, 0.0, (0.25, 0.25))); + v + }, + wave_world, + ), + spec( + "ec.sim.shatter", + "Shatter", + vec![ + p( + "view", + "View", + Value::Enum(1), + popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]), + ), + p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])), + p("pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)), + p("repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("origin", "Origin", pt(0.5, 0.5), ParamUi::Point), + p("extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("force1Position", "Force 1 Position", pt(0.5, 0.5), ParamUi::Point), + p("force1Depth", "Force 1 Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force1Radius", "Force 1 Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force1Strength", "Force 1 Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("force2Position", "Force 2 Position", pt(0.25, 0.25), ParamUi::Point), + p("force2Depth", "Force 2 Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force2Radius", "Force 2 Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force2Strength", "Force 2 Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])), + p("randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("massVariance", "Mass Variance", num(30.0), pct()), + p("gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), + p("gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + shatter, + ), + spec( + "ec.sim.foam", + "Foam", + vec![ + p("view", "View", Value::Enum(0), popup(&["Draft", "Rendered"])), + p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), + p("productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), + p("size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)), + p("growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0, 1.0, 3)), + p("strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("initialDirection", "Initial Direction", num(0.0), ParamUi::Angle), + p("windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("windDirection", "Wind Direction", num(90.0), ParamUi::Angle), + p("turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), + p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), + p("blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), + p("bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + foam, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, hh: u32) -> Image { + let mut img = Image::new(w, hh); + for y in 0..hh { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { + run_fx(id, vals, img, t, EffectEnv::default()).img + } + + fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + fn sum_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() + } + + #[test] + fn piece_at_rest_is_identity() { + let src = ramp(16, 16); + let cam = Cam { c: [8.0, 8.0], d: 64.0 }; + let id = rot_axis([1.0, 0.0, 0.0], 0.0); + let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap(); + let mut out = Image::new(16, 16); + draw_pieces(&mut out, &[pc], &src, None); + assert!(max_diff(&out, &src) < 1e-4); + } + + #[test] + fn ball_action_draws_balls() { + let v = [("ballSize", num(80.0))]; + let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0); + assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0)); + assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps"); + let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0); + assert!(sum_diff(&a, &sc) > 1.0); + } + + #[test] + fn pixel_polly_breaks_after_start() { + let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))]; + let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0); + assert_eq!(before, ramp(32, 32), "intact before the start time"); + let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5); + assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5)); + assert!(sum_diff(&a, &ramp(32, 32)) > 10.0); + let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5); + assert!(sum_diff(&a, &tex) > 0.1); + } + + #[test] + fn scatterize_identity_and_scatter() { + assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24)); + let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0); + assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0)); + assert!(sum_diff(&a, &ramp(24, 24)) > 1.0); + let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0); + assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0); + } + + #[test] + fn card_dance_identity_and_rotation() { + let src = ramp(30, 20); + let a = run("ec.sim.carddance", &[], src.clone(), 0.0); + assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src)); + let r = run("ec.sim.carddance", &[("yRotSource", Value::Enum(1)), ("yRotMultiplier", num(90.0))], src.clone(), 0.0); + assert_eq!(r, run("ec.sim.carddance", &[("yRotSource", Value::Enum(1)), ("yRotMultiplier", num(90.0))], src.clone(), 0.0)); + assert!(sum_diff(&r, &src) > 5.0); + let z = run("ec.sim.carddance", &[("zPosOffset", num(-1.0))], src.clone(), 0.0); + assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow"); + } + + #[test] + fn shatter_at_rest_then_breaks() { + let v = [("view", Value::Enum(0)), ("force1Position", pt(16.0, 16.0)), ("origin", pt(16.0, 16.0))]; + let src = ramp(32, 32); + let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0); + assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src)); + let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5); + assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5)); + assert!(sum_diff(&t1, &src) > 10.0); + for pat in 0..SHATTER_PATTERNS.len() as u32 { + let vv = [("view", Value::Enum(0)), ("pattern", Value::Enum(pat)), ("force1Position", pt(16.0, 16.0)), ("origin", pt(16.0, 16.0))]; + let r = run("ec.sim.shatter", &vv, src.clone(), 0.0); + assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src)); + } + let wire = run("ec.sim.shatter", &[("repetitions", num(3.0))], src.clone(), 0.0); + assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0)); + let layer_only = run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1Position", pt(16.0, 16.0))], src.clone(), 0.5); + assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed"); + } + + #[test] + fn caustics_flat_water() { + let src = ramp(24, 24); + let a = run( + "ec.sim.caustics", + &[("surfaceOpacity", num(0.0)), ("ambientLight", num(0.0)), ("diffuse", num(1.0)), ("lightHeight", num(1000.0)), ("specular", num(0.0))], + src.clone(), + 0.0, + ); + assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src)); + let b = run("ec.sim.caustics", &[], src.clone(), 0.0); + assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0)); + assert!(sum_diff(&b, &src) > 0.1); + } + + #[test] + fn wave_world_seek_and_waves() { + let v = vec![("view", Value::Enum(1)), ("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(24.0))]; + let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5); + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0); + let mut v2 = v.clone(); + v2.push(("transparency", num(0.0))); + assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0)); + // Waves spread: some pixels differ from the flat-water grey. + assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves"); + let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0); + assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); + let wire = run("ec.sim.waveworld", &[("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(10.0))], Image::new(32, 32), 0.3); + assert!(wire.data.iter().any(|p| p[1] > 0.3)); + } + + #[test] + fn wave_world_steps_match_direct() { + // Fresh caches: resumed simulation equals simulation from scratch. + let v = vec![("view", Value::Enum(1)), ("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(20.0)), ("damping", num(0.07))]; + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3); + let mut v2 = v.clone(); + v2.push(("damping", num(0.07000001))); + // Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys). + let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4); + let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3); + assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b)); + } + + #[test] + fn foam_bubbles_drift_and_seek() { + let v = vec![("producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(1)), ("productionRate", num(2.0))]; + let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6); + let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2); + let mut v2 = v.clone(); + v2.push(("randomSeed", num(1.0))); + assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2)); + assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles"); + let draft = run("ec.sim.foam", &[("producerPoint", pt(16.0, 16.0)), ("productionRate", num(2.0))], Image::new(32, 32), 1.0); + assert!(draft.data.iter().any(|p| p[3] > 0.05)); + } } From 702ca1757ffd67d2d7420ef7c11a42bbe56d0ee4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:19:29 -0400 Subject: [PATCH 31/32] M5.7: frame-aligned layer, comp and keyframe times - Comp durations (comp.new / comp.settings) are whole frames. - layer.addItem start/out, layer.timing in/out/start/delta and time-stretch in/out snap to the nearest comp frame; prop.addKey / prop.set with an explicit time key the nearest frame. - The demo project builds every time on 29.97 fps frames (Lower Third in = frame 150 = 0:00:05:00, comp = 300 frames). - Test: demo + commands stay frame-aligned. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 5 +-- crates/engine/src/commands/layer.rs | 13 ++++---- crates/engine/src/commands/layer_time.rs | 8 +++-- crates/engine/src/commands/prop.rs | 12 +++++-- crates/engine/src/demo.rs | 3 +- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 07d341340..1553def13 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -24,7 +24,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { } if let Some(d) = f_p(p, "duration") { let old = c.duration; - c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds())); + // Whole frames, at least one. + c.duration = c.frame_rate.snap_nearest(Tick::from_seconds_f64(d)).max(c.frame_rate.frame_duration()); if c.work_area.1 == old { c.work_area.1 = c.duration; } @@ -53,7 +54,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { fn new_comp(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Comp 1").to_string(); let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97); - let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0)); + let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0))); apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { let mut name = name.clone(); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 946728b28..f988b6117 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -235,12 +235,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result { ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; - let start = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO); + let fr = comp.frame_rate; + let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let id = s.edit("Add Footage to Comp", None, |proj, st| { let mut l = build::layer(proj, &comp, &it.name, src, size, dur); l.start_time = start; l.in_point = start; - l.out_point = (start + dur.unwrap_or(comp.duration)).min(comp.duration); + l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) @@ -524,10 +525,10 @@ fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); let op = str_p(p, "op").unwrap_or("set").to_string(); - let delta = f_p(p, "delta").map(Tick::from_seconds_f64); - let start = f_p(p, "start").map(Tick::from_seconds_f64); - let inp = f_p(p, "in").map(Tick::from_seconds_f64); - let outp = f_p(p, "out").map(Tick::from_seconds_f64); + // Layer times stay on frame boundaries of the comp (as in AE). + let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); + let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index 583d03bb5..2bc031aac 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -25,7 +25,8 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let dur = f_p(p, "duration"); let hold = str_p(p, "hold").unwrap_or("in").to_string(); let cti = s.time(); - let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration(); + let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { @@ -57,8 +58,9 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { l.stretch = new; l.start_time = h - Tick((lt_h.0 as f64 * new / 100.0).round() as i64); let (a, b) = (l.comp_time(lin), l.comp_time(lout)); - l.in_point = a.min(b); - l.out_point = a.max(b); + let fr = comp_rate; + l.in_point = fr.snap_nearest(a.min(b)); + l.out_point = fr.snap_nearest(a.max(b)).max(l.in_point + fr.frame_duration()); } Ok(()) })?; diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 4895bc5f5..9a15be8db 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -38,6 +38,14 @@ fn with_prop( }) } +/// An explicit key time (layer seconds) moved onto the nearest comp frame. +fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option { + let t = Tick::from_seconds_f64(f_p(p, "time")?); + let comp = s.project.comp(cid)?; + let l = comp.layer(lid)?; + Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t)))) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; // Position with Separate Dimensions: write the X/Y/Z Position properties instead. @@ -67,7 +75,7 @@ fn set(s: &mut Session, p: &Value) -> Result { return Ok(json!(out)); } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { let cur = pr.value_at(lt); let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?; @@ -98,7 +106,7 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result { fn add_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?; - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let value = p.get("value").cloned(); with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| { let t = at.unwrap_or(lt); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index eca58e6d7..64ccb1043 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -11,8 +11,9 @@ use effectcraft_time::{FrameRate, Tick}; pub const MAIN_COMP: &str = "EffectCraft Intro"; +/// Seconds → the nearest frame of the demo's 29.97 fps comps (AE keeps times frame-aligned). fn t(s: f64) -> Tick { - Tick::from_seconds_f64(s) + FrameRate::FPS_29_97.snap_nearest(Tick::from_seconds_f64(s)) } fn hex(h: &str) -> [f64; 4] { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6edf3e33f..534054540 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -306,6 +306,48 @@ fn alt_click_stopwatch_toggles_a_self_reference_expression() { assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled); } +#[test] +fn layer_comp_and_key_times_stay_frame_aligned() { + let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t; + // The demo project: every comp duration, layer in/out/start and key time is on a frame. + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for (_, c) in s.project.comps() { + let fr = c.frame_rate; + assert!(aligned(fr, c.duration), "duration {:?}", c.duration); + for l in &c.layers { + for t in [l.in_point, l.out_point, l.start_time] { + assert!(aligned(fr, t), "{} {t:?}", l.name); + } + l.props.walk("", &mut |_, pr| { + for k in &pr.keys { + assert!(aligned(fr, l.comp_time(k.time)), "{} {}", l.name, pr.name); + } + }); + } + } + let lower = s.active_comp().unwrap().layer_by_name("Lower Third").unwrap(); + let fr = s.active_comp().unwrap().frame_rate; + assert_eq!(fr.frame_at(lower.in_point), 150, "5 s at 29.97 is frame 150 (0:00:05:00)"); + // Commands snap: comp duration, layer timing, key times. + s.execute("comp.new", json!({"name": "F", "frameRate": 29.97, "duration": 10.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.frame_rate.frame_at(c.duration), 300); + assert!(aligned(c.frame_rate, c.duration)); + let l = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [l], "in": 1.01, "out": 3.3333, "delta": 0.02})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + let fr = s.active_comp().unwrap().frame_rate; + for t in [ly.in_point, ly.out_point, ly.start_time] { + assert!(aligned(fr, t), "{t:?}"); + } + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": 1.2345, "value": 10})).unwrap(); + let k = prop(&s, l, "transform/opacity").keys[0].time; + assert!(aligned(fr, ly.comp_time(k))); + // Layer shifted one frame (delta 0.02 s rounds to 1 frame): 1.2345 s + 1 frame -> frame 38. + assert_eq!(fr.frame_at(ly.comp_time(k)), 38); +} + #[test] fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { let mut s = Session::default(); From 210ec66ecf0029dbddfc25fdd84b921ad06d1ce9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:32:50 -0400 Subject: [PATCH 32/32] M9.3: render tests for effect masks and layer parameters Co-Authored-By: Claude Opus 5.5 --- crates/render/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4607486c..ad6eec2f4 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -160,3 +160,46 @@ fn effects_run_in_pipeline() { let c = img.get(100, 50); assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } + +fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, size: [f64; 2], vals: &[(&str, Value)]) { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v.clone(); + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); +} + +#[test] +fn effects_see_layer_masks() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::None, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + add_effect(&mut p, &mut l, "ec.generate.stroke", [200.0, 100.0], &[("brushSize", Value::Scalar(6.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.get(30, 50)[0] > 0.5, "stroke on the mask edge: {:?}", img.get(30, 50)); + assert!(img.get(50, 50)[0] < 0.01, "inside untouched"); + assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer"); +} + +#[test] +fn effects_read_layer_params() { + let (mut p, cid, comp) = setup(); + let mut src = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + src.switches.video = false; + let src_id = src.id.0; + let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect(&mut p, &mut top, "ec.channel.blend", [200.0, 100.0], &[("blendWithLayer", Value::Layer(Some(src_id)))]); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(top); + c.layers.push(src); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let px = img.get(100, 50); + assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}"); +}