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/.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/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/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 87de1a5fa..9d3c76d37 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" @@ -1048,16 +1058,30 @@ dependencies = [ "effectcraft-ui-egui", "eframe", "egui", + "objc2-foundation 0.3.2", "rfd", "serde_json", "winit", ] [[package]] -name = "effectcraft-cli" +name = "effectcraft-automation" version = "0.1.0" dependencies = [ "effectcraft-engine", + "image", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "effectcraft-cli" +version = "0.1.0" +dependencies = [ + "cpu-time", + "effectcraft-automation", + "effectcraft-engine", + "effectcraft-host", "effectcraft-render", "effectcraft-time", "image", @@ -1104,6 +1128,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 +1177,7 @@ name = "effectcraft-host" version = "0.1.0" dependencies = [ "effectcraft-engine", + "effectcraft-export", "effectcraft-expr", "effectcraft-media", "effectcraft-project", @@ -1249,6 +1295,7 @@ dependencies = [ "effectcraft-color", "effectcraft-engine", "effectcraft-geom", + "effectcraft-host", "effectcraft-keyframe", "effectcraft-path", "effectcraft-project", @@ -1697,6 +1744,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" @@ -5041,6 +5098,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" @@ -5050,6 +5123,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" @@ -5466,6 +5545,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/Cargo.toml b/Cargo.toml index 4165cfef2..bd1d4b20d 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] @@ -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..2adf08732 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -14,7 +14,10 @@ path = "src/main.rs" [dependencies] effectcraft-engine = { workspace = true } +effectcraft-host = { workspace = true } +effectcraft-automation = { workspace = true } 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 71c7c4f6e..43e758378 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1,90 +1,666 @@ -//! 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 render [--comp C] --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] +//! effectcraft-cli render F.ecproj --queue render the project's Render Queue +//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] render timings +//! 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] + render [--comp C] --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 PROFILE] [--audio auto|on|off] | --queue + bench [--comp C] [--time S] [--scale K] [--n N] [--play N] per-layer/effect render timings; + --play N renders N consecutive frames with/without the layer cache + 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] = &[ + "--n", + "--play", + "--params", + "--project", + "--bridge", + "--save-as", + "--filter", + "--time", + "--frame", + "--comp", + "--max-side", + "--scale", + "--out", + "--expression", + "--depth", + // render + "--format", + "--start", + "--end", + "--fps", + "--resolution", + "--quality", + "--channels", + "--jpeg-quality", + "--bitrate", + "--prores", + "--audio", +]; + +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 { - // No project given (or `--demo`): open the demo project. - 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 { + "render" => render(args, json_out)?, + "bench" => bench_cmd(args)?, + "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}), } } + +/// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then +/// render the queue with a progress line on stderr. Fails if any item fails. +fn render(args: &Args, json_out: bool) -> Result<(), Failure> { + use effectcraft_engine::project::render_queue::RenderStatus; + if args.opt("--bridge").is_some() { + return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge"); + } + let mut s = effectcraft_host::session(); + match &args.project { + Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, + None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, + }; + let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string()); + if !args.flag("--queue") { + let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") }; + let mut p = json!({"output": out}); + if let Some(c) = args.opt("--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) = args.opt("--format").map(str::to_string).or(ext) { + p["format"] = json!(f); + } + match (args.num("--start")?, args.num("--end")?) { + (None, None) => p["timeSpan"] = json!(if args.flag("--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) = args.num("--fps")? { + p["frameRate"] = json!(v); + } + if let Some(r) = args.opt("--resolution").or(args.opt("--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) = args.opt(flag) { + p[key] = json!(v); + } + } + if let Some(v) = args.num("--jpeg-quality")? { + p["quality"] = json!(v); + } + if let Some(v) = args.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).map_err(err)?; + 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})).map_err(err)?; + 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!( + " + [{}/{}] 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 results = vec![]; + let mut failed = vec![]; + 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.clone()).unwrap_or_else(|| "?".into()); + 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)); + results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time})); + } + RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")), + other => failed.push(format!("{name}: {}", other.label())), + } + } + if !failed.is_empty() { + return Err(Failure::Error(failed.join("; "))); + } + if json_out { + emit(&json!({"rendered": results}), true); + } + Ok(()) +} + +/// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown), +/// and optionally `--play N` consecutive frames with and without the cache. +fn bench_cmd(args: &Args) -> Result<(), Failure> { + let mut s = effectcraft_host::session(); + match &args.project { + Some(p) => s.execute("file.open", json!({"path": p})), + None => s.execute("file.openDemoProject", json!({})), + } + .map_err(|e| Failure::Error(e.to_string()))?; + if let Some(c) = args.opt("--comp") { + s.execute("comp.open", json!({"comp": c})).map_err(|e| Failure::Error(e.to_string()))?; + } + let cid = s.active_comp_id().ok_or_else(|| Failure::Error("no composition".into()))?; + let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0)); + let opts = RenderOpts { scale: args.num("--scale")?.unwrap_or(1.0), ..Default::default() }; + bench(&s, cid, t, opts, args.num("--n")?.unwrap_or(10.0).max(1.0) as usize); + if let Some(n) = args.num("--play")? { + bench_play(&s, cid, t, opts, n.max(1.0) as usize); + } + Ok(()) +} + +// ---------------------------------------------------------------- benchmark helpers + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_render::{LayerCache, LayerTiming, RenderOpts, Renderer}; +use effectcraft_time::Tick; + +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); +} 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/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 c0894e8cb..9e8bcd6fd 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/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/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 95853e7ca..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", @@ -621,7 +629,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..259849846 --- /dev/null +++ b/crates/effects/src/channel2.rs @@ -0,0 +1,448 @@ +//! 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 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 { + 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); + } +} 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..768407747 --- /dev/null +++ b/crates/effects/src/color3.rs @@ -0,0 +1,575 @@ +//! 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 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 { + 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); + } +} 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..e7ca49106 --- /dev/null +++ b/crates/effects/src/distort3.rs @@ -0,0 +1,1056 @@ +//! 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 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 { + 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); + } +} 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..87d828673 --- /dev/null +++ b/crates/effects/src/generate3.rs @@ -0,0 +1,1888 @@ +//! 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 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 { + 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); + } +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 106a9d2f6..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)), @@ -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..caad75ec7 --- /dev/null +++ b/crates/effects/src/keying2.rs @@ -0,0 +1,419 @@ +//! 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 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![ + 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)); + } + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa25..6b8b7a97b 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 }) @@ -222,15 +303,69 @@ 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", + "ec.obsolete.lightning", + "ec.text.timecode", + "ec.text.numbers", +]; + +/// See [`TIME_DEPENDENT`]. +pub fn is_time_dependent(id: &str) -> bool { + // Simulations replay from layer time 0, so every frame differs even when the defaults + // happen to look static at the test's two sample times. + id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) +} + /// Run an effect on a buffer. 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,10 +394,42 @@ 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); } } + + /// 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]); + } + } + let mut missing = vec![]; + 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, env: Default::default() }; + 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 && !is_time_dependent(s.id) { + missing.push(s.id); + } + } + assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}"); + } } 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..c53ada322 --- /dev/null +++ b/crates/effects/src/noise2.rs @@ -0,0 +1,369 @@ +//! 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 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 { + 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)); + } +} diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs new file mode 100644 index 000000000..122ec32d2 --- /dev/null +++ b/crates/effects/src/obsolete.rs @@ -0,0 +1,184 @@ +//! Obsolete-category effects: Basic 3D (tilt/swivel the layer in a simple perspective space +//! with an optional specular highlight) and Gaussian Blur (Legacy). + +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![ + 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}"); + } + } +} 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..feed02864 --- /dev/null +++ b/crates/effects/src/perspective2.rs @@ -0,0 +1,286 @@ +//! 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 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![ + 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/sim.rs b/crates/effects/src/sim.rs new file mode 100644 index 000000000..024a91fc0 --- /dev/null +++ b/crates/effects/src/sim.rs @@ -0,0 +1,1530 @@ +//! 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 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 { + 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 new file mode 100644 index 000000000..133dc87e7 --- /dev/null +++ b/crates/effects/src/sim2.rs @@ -0,0 +1,1610 @@ +//! 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 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![ + 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)); + } +} 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..df46c97ed --- /dev/null +++ b/crates/effects/src/stylize3.rs @@ -0,0 +1,659 @@ +//! 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 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 { + 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); + } +} diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs new file mode 100644 index 000000000..4a4a8d35c --- /dev/null +++ b/crates/effects/src/textfx.rs @@ -0,0 +1,981 @@ +//! 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 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 { + 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); + } +} 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..2e1738b28 --- /dev/null +++ b/crates/effects/src/transition2.rs @@ -0,0 +1,544 @@ +//! 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 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 { + 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); + } +} 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..ec7681970 --- /dev/null +++ b/crates/effects/src/utility2.rs @@ -0,0 +1,85 @@ +//! 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 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![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:?}"); + } + } + } +} 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/comp.rs b/crates/engine/src/commands/comp.rs index 07d341340..f00b9b519 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -13,18 +13,57 @@ 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; } 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 +92,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(); @@ -205,7 +244,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), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}", always, new_comp ), @@ -214,7 +253,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?, 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/commands/edit.rs b/crates/engine/src/commands/edit.rs index b68df62de..027c23b65 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 { @@ -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)?; @@ -97,18 +101,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 +207,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 +220,10 @@ pub fn specs() -> Vec { ] } +fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { + 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> { Ok(()) } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index dba27cca9..94559f83c 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -95,12 +95,36 @@ 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; } + 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; } @@ -108,10 +132,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, }; } @@ -228,12 +256,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})) @@ -267,6 +296,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::>())) } @@ -479,10 +510,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(()) })?; @@ -493,10 +576,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(); @@ -813,34 +896,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")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). @@ -960,7 +1015,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?, smallCaps?, fauxBold?, fauxItalic?, hScale? %, vScale? %, baselineShift? px, strokeOverFill?}", has_layers, set_text ), @@ -982,7 +1037,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..2bc031aac --- /dev/null +++ b/crates/engine/src/commands/layer_time.rs @@ -0,0 +1,188 @@ +//! 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 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)) { + 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)); + 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(()) + })?; + 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/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 42b5d1225..4abdc7a8e 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,14 +1,19 @@ //! 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 mask; mod prop; mod query; +mod render_queue; mod three_d; mod time; @@ -70,9 +75,14 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(comp::specs()); v.extend(layer::specs()); v.extend(three_d::specs()); + v.extend(layer_time::specs()); 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(render_queue::specs()); v.extend(help::specs()); v.extend(query::specs()); v @@ -83,6 +93,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> { diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 43bd88e6d..1c5313e4a 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -6,10 +6,10 @@ 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)> { +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) { @@ -38,10 +38,44 @@ 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. + 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 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())))?; @@ -57,6 +91,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"); @@ -72,7 +121,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); @@ -113,8 +162,18 @@ 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); + } + // 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, (Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }), @@ -122,20 +181,20 @@ 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()))) - }) -} - -fn default_expr(pr: &Property) -> String { - let _ = pr; - "value".to_string() + })?; + if let Some(e) = error { + s.toast(format!("Expression disabled: {e}")); + return Ok(json!({"expression": r, "error": e})); + } + Ok(r) } fn reset(s: &mut Session, p: &Value) -> Result { @@ -210,7 +269,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 +306,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 +369,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 +437,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 +469,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 @@ -404,6 +561,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), @@ -435,7 +593,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 +602,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/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/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/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/demo.rs b/crates/engine/src/demo.rs index f356bf59c..6be2e12bb 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -14,8 +14,9 @@ use effectcraft_time::{FrameRate, Tick}; pub const MAIN_COMP: &str = "EffectCraft Intro"; pub const SHOWCASE_3D: &str = "3D Showcase"; +/// 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/lib.rs b/crates/engine/src/lib.rs index 1f4be7ca5..7dd18722c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,12 +11,13 @@ pub mod commands; pub mod demo; pub mod links; +pub mod render_queue; 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; @@ -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 { @@ -91,6 +93,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 +127,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, @@ -134,10 +163,18 @@ 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>, + /// 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)>, + /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). + pub layer_cache: Arc, } impl Default for Session { @@ -152,9 +189,13 @@ impl Default for Session { services: Arc::new(FsServices), footage: Arc::new(NoFootage), expr: None, + expr_check: None, importer: None, + exporter: None, + render_job: None, events: vec![], journal: vec![], + layer_cache: Arc::new(LayerCache::default()), } } } @@ -184,6 +225,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()) } @@ -246,6 +295,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()); } @@ -270,7 +320,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); @@ -290,6 +340,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)); } @@ -315,11 +366,38 @@ 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) } + /// 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) { + 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(); @@ -334,10 +412,14 @@ impl Session { } } +#[cfg(test)] +mod rq_tests; #[cfg(test)] mod tests; #[cfg(test)] mod tests_3d; +#[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/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/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52f..0e849f929 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(); @@ -114,3 +143,158 @@ 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); + 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] +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, "frameRate": 30})).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}"); +} + +#[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/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs new file mode 100644 index 000000000..534054540 --- /dev/null +++ b/crates/engine/src/tests_timeline.rs @@ -0,0 +1,573 @@ +//! 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 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(); + 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 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(); + 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(); + 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/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..d87e0858e --- /dev/null +++ b/crates/export/src/lib.rs @@ -0,0 +1,352 @@ +//! 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, + // Output renders always look through the comp's active camera. + view: None, + }; + 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/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/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 9eb8ee7eb..c8ed03bc4 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,12 +16,39 @@ 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 { 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)), + expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() } } @@ -38,4 +67,101 @@ 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); + } + + /// 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/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/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/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/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/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/audio.rs b/crates/render/src/audio.rs new file mode 100644 index 000000000..d7ae47788 --- /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).unwrap_or_else(|| vec![0.0; src_n * 2]), + _ => 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) -> Option> { + Some((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/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/eval.rs b/crates/render/src/eval.rs index 4e158dc55..7271d36ac 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() { // Children of cameras/lights follow their position and full rotation. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5b39adac..02f61df67 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,8 @@ //! 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 cache; pub mod eval; pub mod masks; pub mod shapes; @@ -14,8 +16,9 @@ pub mod three_d; use std::sync::Arc; +pub use cache::{CacheStats, LayerCache}; 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; @@ -28,6 +31,44 @@ 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 { depth: self.r.depth + 1, ..*self.r }; + // The cached layer buffer is shared; effects get their own copy. + let buf = if masks_and_effects { (*sub.layer_buf(self.ctx, other)?).clone() } 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). @@ -62,13 +103,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> { @@ -125,7 +185,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; } @@ -133,6 +197,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; @@ -140,8 +207,12 @@ 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 }; + 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 } @@ -164,8 +235,8 @@ 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 lt = layer.layer_time(ctx.time); + let sub = Renderer { depth: self.depth + 1, ..*self }; + let lt = ctx.source_time(layer); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -174,7 +245,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. @@ -192,10 +263,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 { @@ -231,25 +320,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); } @@ -263,16 +341,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) @@ -331,6 +427,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/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 }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4607486c..dc1f3cdaf 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -160,3 +160,211 @@ 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) +} + +/// [`add_effect`] for a 200×100 layer. +fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) { + add_effect(p, l, id, [200.0, 100.0], vals); +} + +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()); +} + +/// 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_200(&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_200(&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_200(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_200(&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"); +} + +#[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:?}"); +} 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/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 95c45d986..fb307b8c2 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -208,8 +208,9 @@ pub fn rig_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { /// Direction of travel of a layer's position (parent space) at the context time. fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { let tr = layer.transform()?; - let p = tr.get("position")?; - if p.keys.len() < 2 && !p.has_expression() { + // Position (or its separated X/Y/Z dimensions) must be animated. + let moving = ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.keys.len() > 1 || p.has_expression())); + if !moving { return None; } let dt = effectcraft_time::Tick::from_seconds_f64(0.005); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d00d6694..388dab744 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -13,6 +13,8 @@ //! alpha-shaped and coloured shadows through Light Transmission); Shadow Diffusion softens them //! with a penumbra that grows with the distance between caster and receiver. +use std::sync::Arc; + use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; @@ -44,7 +46,7 @@ pub(crate) struct Item<'a> { pub layer: &'a Layer, /// Position in the layer stack (higher = above). order: usize, - buf: Buf, + buf: Arc, bicubic: bool, geos: Vec, mat: Material, @@ -181,8 +183,12 @@ pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, ord let sigma = radius / 1.5; let pad = (sigma * 3.0).ceil() as u32 + 1; let padded = buf.img.padded(pad); - buf.img = effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false); - buf.offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. + buf = Arc::new(Buf { + img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), + offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], + scale: buf.scale, + }); } } let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index f5841d087..79e91f9cc 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -86,3 +86,45 @@ fn camera_parented_to_towards_camera_layer_does_not_recurse() { c.layers.push(l); let _ = crate::render_frame(&p, cid, Tick::ZERO, 0.5); } + +/// The layer cache holds layer pixels only; camera, lights, DOF and the view override act at +/// compositing time, so cached renders must match uncached ones after any of them change. +#[test] +fn layer_cache_is_independent_of_camera_lights_and_view() { + let (mut p, cid, comp) = setup(); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + cam.props.prop_mut("cameraOptions/dof").unwrap().value = Value::Bool(true); + cam.props.prop_mut("cameraOptions/aperture").unwrap().value = Value::Scalar(40.0); + let light = build::layer(&mut p, &comp, "Light", LayerSource::Light { kind: effectcraft_project::LightKind::Point }, (200, 100), None); + let a = solid(&mut p, &comp, true, [80.0, 50.0, 0.0]); + let b = solid(&mut p, &comp, true, [120.0, 50.0, 150.0]); + let c = p.comp_mut(cid).unwrap(); + c.layers.extend([cam, light, a, b]); + let cache = crate::LayerCache::new(64 << 20); + let render = |p: &Project, cached: bool, view: Option| { + let opts = crate::RenderOpts { view, motion_blur: false, ..Default::default() }; + let mut r = crate::Renderer::new(p, &crate::NoFootage, opts); + if cached { + r.cache = Some(&cache); + } + r.comp_frame(cid, Tick::ZERO) + }; + let check = |p: &Project, view: Option| { + let warm = render(p, true, view); + assert_eq!(render(p, true, view), warm); + assert_eq!(render(p, false, view), warm, "cached render differs from uncached"); + warm + }; + let first = check(&p, None); + // Move the camera, refocus, change the light: cached pixels must follow. + let c = p.comp_mut(cid).unwrap(); + c.layers[0].props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, -250.0]); + c.layers[0].props.prop_mut("cameraOptions/focusDistance").unwrap().value = Value::Scalar(400.0); + c.layers[1].props.prop_mut("lightOptions/intensity").unwrap().value = Value::Scalar(40.0); + let second = check(&p, None); + assert_ne!(first, second, "camera/light changes re-render"); + let top = super::default_view_cam(super::View3D::Top, 200.0, 100.0).state(); + let third = check(&p, Some(top)); + assert_ne!(third, second); + assert!(cache.stats().hits > 0, "the cache was actually used"); +} 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/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..e98acbdaf --- /dev/null +++ b/crates/ui-egui/examples/snapshot.rs @@ -0,0 +1,139 @@ +//! 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) +} + +/// 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 { + step_frame(h); + std::thread::sleep(Duration::from_millis(30)); + } + step_frame(h); +} + +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 { + step_frame(&mut harness); + 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/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/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/frames.rs b/crates/ui-egui/src/frames.rs index a60f74e8e..129d510d2 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 { @@ -51,14 +52,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>, } @@ -68,6 +73,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)), @@ -80,7 +86,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; @@ -125,36 +131,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(); @@ -162,3 +224,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 a8e3f0851..606136057 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,9 @@ pub enum Dialog { CommandPalette, CameraSettings, LightSettings, + KeyVelocity, + KeyInterpolation, + TimeStretch, } /// Host hooks provided by the native app (file pickers etc.). @@ -164,7 +167,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. @@ -195,13 +203,26 @@ impl EffectcraftApp { h.finish() | 1 } - /// 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, view: self.session.view_camera(comp) }; - 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 @@ -243,7 +264,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 { @@ -330,6 +359,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; @@ -417,6 +452,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() { @@ -478,14 +517,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 { @@ -511,6 +567,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/menus.rs b/crates/ui-egui/src/menus.rs index 00e95c378..efc9efbfc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -249,6 +249,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)?; } @@ -260,6 +263,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/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 a189d496d..e277af46a 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}; -#[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 { @@ -36,6 +21,9 @@ pub struct DialogState { pub palette_sel: usize, pub camera: super::dialogs_3d::CameraDraft, pub light: super::dialogs_3d::LightDraft, + 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) { @@ -52,10 +40,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); @@ -99,6 +93,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::CommandPalette => palette(app, ctx, &t), Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t), Dialog::LightSettings => super::dialogs_3d::light(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), } } @@ -176,93 +173,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 => {} } } @@ -276,23 +205,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; @@ -302,6 +263,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; diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bb418ccd9..f173adeb3 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}; @@ -90,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(_) => { @@ -140,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)); @@ -188,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, @@ -213,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; @@ -292,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"); @@ -310,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); @@ -376,41 +417,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/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/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), 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/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 63f83d2b5..8cd26cb5e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,12 +1,17 @@ //! Panel bodies. +pub mod comp_settings; pub mod dialogs; pub mod dialogs_3d; 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 properties; +pub mod render_queue; pub mod text_panels; pub mod timeline; pub mod viewer; @@ -33,11 +38,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), } } @@ -59,12 +61,12 @@ 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), 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/project.rs b/crates/ui-egui/src/panels/project.rs index 939eaed0d..721540f03 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); @@ -260,3 +333,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/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); + } +} 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/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()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b63f53071..a7ff23385 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)] @@ -65,17 +111,17 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols { /// 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 } } @@ -144,6 +190,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { if p.match_id == "poi" && (layer.is_camera() || layer.is_light()) && layer.auto_orient != effectcraft_engine::project::AutoOrient::TowardsPointOfInterest { 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 } @@ -171,7 +221,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"]), @@ -325,7 +375,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 +400,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 +512,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); @@ -523,9 +586,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)); @@ -551,16 +614,27 @@ 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)); + // 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() { - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), rh)); - y += rh; + // Outline widgets never spill into the time graph (narrow timelines). + ui.set_clip_rect(left_clip); + 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( @@ -742,7 +816,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"); @@ -758,6 +840,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()); @@ -876,6 +959,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)); @@ -965,8 +1117,9 @@ 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) { + 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, @@ -1005,15 +1158,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()}]}))); @@ -1043,12 +1213,35 @@ 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 { - 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()); if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); } @@ -1068,7 +1261,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 @@ -1144,8 +1337,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) => { @@ -1192,6 +1384,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; } @@ -1261,7 +1457,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 { @@ -1416,86 +1616,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 8757fd960..3989b8cb4 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -71,7 +71,6 @@ enum Gesture { start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, - uniform: bool, }, Rotate { layer: LayerId, @@ -97,6 +96,48 @@ enum Gesture { 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; @@ -238,11 +279,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 { @@ -301,6 +346,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); @@ -350,14 +401,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))); } } } @@ -462,10 +536,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 }), @@ -496,6 +591,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::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }), + 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(); @@ -506,13 +617,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); let lp = inv.apply(gv2(cpt[0], cpt[1])); let _ = hi; - 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, - 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], [f64; 3], [f64; 3])> = app @@ -560,14 +665,13 @@ 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 _ = uniform; let (fx, fy) = if mods.shift { let f = (fx + fy) / 2.0; (f, f) @@ -644,6 +748,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)); @@ -700,6 +830,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!({})); @@ -708,6 +845,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); @@ -827,6 +965,51 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } +/// 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 = l2c(ectx, 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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 05a85121b..b06b0e388 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 { @@ -258,16 +280,25 @@ 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(), 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(), } } } +fn default_project_sort() -> String { + "name".into() +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, @@ -286,8 +317,17 @@ 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, + /// 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], @@ -318,7 +358,10 @@ 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(), + 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, 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(); +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 000000000..54a5ff38d --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,108 @@ +# 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"] + } + } +} +``` + +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`. + +- **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/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/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). 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 + } + } +}