diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 000000000..2a4cd2cb9
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,3 @@
+# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
+# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
+nextest-version = "0.9.150"
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index e78fe1ea3..8f1aa5ad5 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -16,6 +16,9 @@ concurrency:
jobs:
sonarcloud:
- # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ # Track shared CI improvements on main.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ with:
+ timeout-minutes: 120
+ fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
secrets: inherit
diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
index 538887d9b..c7a905e58 100644
--- a/apps/effectcraft-cli/tests/it/cli.rs
+++ b/apps/effectcraft-cli/tests/it/cli.rs
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
use serde_json::{Value, json};
fn bin() -> Command {
- Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
+ Command::new(crate::binary())
}
/// Run with `--json`; returns (exit code, parsed stdout).
diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs
index 17149f545..bca36ec78 100644
--- a/apps/effectcraft-cli/tests/it/main.rs
+++ b/apps/effectcraft-cli/tests/it/main.rs
@@ -2,3 +2,8 @@
mod cli;
mod qa_template;
+
+/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
+fn binary() -> std::ffi::OsString {
+ std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into())
+}
diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs
index 0618c0d63..8cb533fda 100644
--- a/apps/effectcraft-cli/tests/it/qa_template.rs
+++ b/apps/effectcraft-cli/tests/it/qa_template.rs
@@ -9,7 +9,7 @@ use std::process::Command;
use serde_json::Value;
fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) {
- let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap();
+ let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr)));
(out.status.code().unwrap_or(-1), v)
@@ -191,7 +191,7 @@ fn essential_graphics_template_via_script_and_commands() {
vec!["render", "--template", "lt.ectemplate", "--queue"],
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
] {
- let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
+ let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
@@ -251,7 +251,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_before = std::fs::read(dir.join("values.json")).unwrap();
// Read the inputs before interpreting the subprocess status or error text.
- let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
+ let result = Command::new(crate::binary())
.current_dir(dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
.output()
@@ -527,7 +527,7 @@ fn template_unknown_control_does_not_replace_extracted_media() {
let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap();
// Capture every file before interpreting status/JSON, so a failed command cannot hide a write.
- let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
+ let result = Command::new(crate::binary())
.current_dir(&dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
.output()
@@ -623,11 +623,8 @@ fn template_missing_output_does_not_replace_extracted_media() {
assert_ne!(sentinel_before, source_before);
// Observe writes before interpreting the actual CLI usage exit.
- let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
- .current_dir(&dir)
- .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"])
- .output()
- .unwrap();
+ let result =
+ Command::new(crate::binary()).current_dir(&dir).args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]).output().unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_after = std::fs::read(dir.join("values.json")).unwrap();
let source_after = std::fs::read(dir.join("source.png")).unwrap();
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index f09bf28a1..169529e5d 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -86,6 +86,8 @@ pub struct WebApp {
}
impl eframe::App for WebApp {
+ /// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously
+ /// through `open_or_import`, then run the shared app's input hook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Files dropped on the page are input: read them here, where the browser's drop event is
// still the current pass's, not from a later (possibly logic-only) tick.
@@ -101,6 +103,9 @@ impl eframe::App for WebApp {
self.app.raw_input_hook(ctx, raw_input);
}
+ /// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render
+ /// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close
+ /// the IME pass.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
for f in actions {
diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs
index 0d59906f2..97d2e887f 100644
--- a/apps/effectcraft-web/src/lib.rs
+++ b/apps/effectcraft-web/src/lib.rs
@@ -6,17 +6,17 @@
//!
//! | Desktop | Web |
//! |---|---|
-//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
+//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) |
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
//! | rfd file dialogs | `` pickers; drop files on the page |
-//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
+//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) |
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
-//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers |
-//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) |
-//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) |
-//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
-//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
-//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
+//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers |
+//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) |
+//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) |
+//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) |
+//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
+//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
//!
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 5e3e14c65..6df015290 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -148,6 +148,10 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
@@ -451,6 +455,7 @@ struct Desktop {
}
impl eframe::App for Desktop {
+ /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs
index 8367d2a99..41281eb23 100644
--- a/apps/effectcraft/tests/args.rs
+++ b/apps/effectcraft/tests/args.rs
@@ -6,6 +6,11 @@ use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
+/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
+fn binary() -> std::ffi::OsString {
+ std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into())
+}
+
/// A settings folder of its own, so a launch that got past the arguments would show up as
/// preferences (Recent Footage) written there instead of in the user's settings.
fn config_dir(name: &str) -> PathBuf {
@@ -19,7 +24,7 @@ fn config_dir(name: &str) -> PathBuf {
/// regression fails instead of leaving a window open: the process is killed after 5 s.
fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) {
let config = config_dir(name);
- let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft"));
+ let mut cmd = Command::new(binary());
cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config);
for (k, v) in env {
cmd.env(k, v);
@@ -67,7 +72,7 @@ fn a_bad_control_port_stops_the_launch() {
#[test]
fn version_still_prints_with_a_bad_port_variable() {
- let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
+ let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION")));
}
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 0be2b04f5..80a937b22 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -92,7 +92,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
];
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
-/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
+/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked
/// pinning).
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
match id {
diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs
index 39a1c49b1..08241107c 100644
--- a/crates/effects/src/channel3d.rs
+++ b/crates/effects/src/channel3d.rs
@@ -359,7 +359,7 @@ pub(crate) fn selection_names(sel: &str) -> Vec {
out
}
-/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
+/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or
/// `` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec {
let mut out = vec![];
@@ -686,384 +686,4 @@ pub fn specs() -> Vec {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
- use effectcraft_raster::channels3d::crypto_float;
-
- /// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
- /// top-right corner is background. Cryptomatte: "left" / "right".
- struct AuxHost(Arc);
- impl EffectHost for AuxHost {
- fn layer(&self, _: u64, _: bool) -> Option {
- None
- }
- fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
- None
- }
- fn aux(&self) -> Option> {
- Some(self.0.clone())
- }
- }
-
- fn scene() -> AuxHost {
- let (w, h) = (20usize, 10usize);
- let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
- let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
- let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
- for y in 0..h {
- for x in 0..w {
- let i = y * w + x;
- if x >= 15 && y < 3 {
- z[i] = BACKGROUND_DEPTH;
- } else if x < 10 {
- z[i] = 100.0;
- id[i] = 1.0;
- r[i] = crypto_float(hl);
- g[i] = 1.0;
- } else {
- z[i] = 500.0;
- id[i] = 2.0;
- r[i] = crypto_float(hr);
- g[i] = 1.0;
- }
- }
- }
- a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
- a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
- AuxHost(Arc::new(a))
- }
-
- fn img() -> Image {
- Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
- }
-
- fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
- run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
- }
-
- #[test]
- fn passes_through_without_aux_channels() {
- for s in specs() {
- let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
- assert_eq!(out.img.data, img().data, "{}", s.id);
- }
- }
-
- #[test]
- fn depth_matte_thresholds() {
- let h = scene();
- let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
- assert_eq!(o.get(2, 5)[3], 0.0);
- assert_eq!(o.get(12, 5)[3], 1.0);
- let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
- assert_eq!(inv.get(2, 5)[3], 1.0);
- assert_eq!(inv.get(12, 5)[3], 0.0);
- // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
- let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
- assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
- }
-
- #[test]
- fn channel_extract_maps_depth_and_ids() {
- let h = scene();
- let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
- assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
- assert_eq!(o.get(17, 1)[0], 1.0);
- let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
- assert_eq!(ids.get(2, 5)[0], 0.5);
- // Invert Depth Map flips the ramp; without Clamp Output values run past 1.
- let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h);
- assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6);
- let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h);
- assert!(raw.get(12, 5)[0] > 1.5);
- }
-
- #[test]
- fn id_matte_and_identifier() {
- let h = scene();
- let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
- assert_eq!(o.get(2, 5)[3], 0.0);
- assert_eq!(o.get(12, 5)[3], 1.0);
- let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
- assert_eq!(l.get(2, 5)[0], 1.0);
- assert_eq!(l.get(12, 5)[0], 0.0);
- let c = run("ec.3d.identifier", &[], &h);
- assert_ne!(c.get(2, 5), c.get(12, 5));
- }
-
- /// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel
- /// reduction over `crypto_ranks` it replaces.
- #[test]
- fn crypto_aux_image_matches_the_per_pixel_reduction() {
- let (w, h) = (7u32, 5u32);
- let n = (w * h) as usize;
- let mut a = AuxChannels::new(w, h, 1.0);
- let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float);
- for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() {
- let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect();
- a.channels.push((format!("CryptoObject{ch}"), v));
- }
- let sel = vec![crypto_hash("sphere"), crypto_hash("ring")];
- let img = crypto_aux_image(&a, "CryptoObject", &sel);
- for i in 0..n {
- let ranks = a.crypto_ranks("CryptoObject", i);
- let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0);
- let mut c = [0.0f32; 3];
- for (id, cov) in &ranks {
- let k = id_color(*id);
- for j in 0..3 {
- c[j] += k[j] * cov;
- }
- }
- let want = [c[0], c[1], c[2], m];
- assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
- }
- }
-
- /// The lookup before #473, kept as the reference: the rank channels formatted and searched
- /// by name for every pixel.
- fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> {
- let mut out = vec![];
- for k in 0.. {
- let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}"));
- let (Some(r), Some(g)) = (get("R"), get("G")) else { break };
- out.push((r[i], g[i]));
- if let (Some(b), Some(al)) = (get("B"), get("A")) {
- out.push((b[i], al[i]));
- }
- }
- out
- }
-
- /// The reduction before #473 over those ranks: (ID colours, selection coverage).
- fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) {
- let ranks = ranks_by_name(a, layer, i);
- let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0);
- let mut c = [0.0f32; 3];
- for (id, cov) in &ranks {
- let k = id_color(*id);
- for j in 0..3 {
- c[j] += k[j] * cov;
- }
- }
- (c, m)
- }
-
- /// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN
- /// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values
- /// that would change every pixel) must be ignored as before.
- fn edge_aux(with_rank_after_gap: bool) -> AuxChannels {
- let (w, h) = (6u32, 2u32);
- let nan_payload = f32::from_bits(0x7fc0_1234);
- let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube")));
- let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5];
- let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0];
- let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() };
- let mut a = AuxChannels::new(w, h, 1.0);
- let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v));
- push("00.R", rot(&ids, 0));
- push("00.G", rot(&cov, 0));
- push("00.B", rot(&ids, 3));
- push("00.A", rot(&cov, 5));
- push("01.R", rot(&ids, 7));
- push("01.G", rot(&cov, 2));
- if with_rank_after_gap {
- push("03.R", vec![hs; 12]);
- push("03.G", vec![1.0; 12]);
- push("03.B", vec![hc; 12]);
- push("03.A", vec![1.0; 12]);
- }
- a
- }
-
- /// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values
- /// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits).
- #[test]
- fn id_color_memo_matches_id_color() {
- let a = edge_aux(true);
- let planes = a.crypto_rank_planes("CryptoObject");
- let mut memo = IdColorMemo::default();
- for i in 0..12 {
- for (r, (id, _)) in planes.iter().enumerate() {
- assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}");
- }
- }
- }
-
- fn edge_selection() -> Vec {
- vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
- }
-
- /// Edge values give bit-identical results to the lookup by name, the missing B/A pair is
- /// skipped and a rank after a gap is ignored.
- #[test]
- fn crypto_aux_image_is_bit_exact_on_edge_values() {
- let sel = edge_selection();
- let a = edge_aux(true);
- assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap");
- let img = crypto_aux_image(&a, "CryptoObject", &sel);
- let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel);
- for i in 0..12 {
- let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i);
- let want = [c[0], c[1], c[2], m];
- assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
- assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used");
- }
- }
-
- /// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values.
- #[test]
- fn crypto_aux_image_without_colors_keeps_the_matte() {
- let a = edge_aux(true);
- let sel = edge_selection();
- let full = crypto_aux_image(&a, "CryptoObject", &sel);
- let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly);
- for (f, m) in full.data.iter().zip(&matte.data) {
- assert_eq!(f[3].to_bits(), m[3].to_bits());
- assert_eq!(m[..3], [0.0; 3]);
- }
- }
-
- /// Only the Colors display without Matte Only reads the ID colours.
- #[test]
- fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() {
- assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte);
- assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly);
- for display in [1, 2, 3, u32::MAX] {
- for matte_only in [false, true] {
- assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}");
- }
- }
- }
-
- /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
- /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
- /// get an empty matte and no ID colour.
- #[test]
- fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() {
- let a = edge_aux(true);
- let host = AuxHost(Arc::new(a.clone()));
- let names = "sphere, <7fc00000>, <7fc01234>, <80000000>";
- assert_eq!(selection_hashes(names, None), edge_selection());
- let src = img().data[0];
- for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] {
- let out = run(
- "ec.3d.cryptomatte",
- &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))],
- &host,
- );
- let expect = |c: [f32; 3], m: f32| -> [f32; 4] {
- if matte_only {
- return [m; 4];
- }
- match display {
- 0 => {
- let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
- [c[0], c[1], c[2], 1.0]
- }
- 1 => src.map(|v| v * m),
- _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]],
- }
- };
- for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] {
- let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize);
- assert_eq!(
- out.get(x as i64, y as i64).map(f32::to_bits),
- expect(c, m).map(f32::to_bits),
- "display {display} matteOnly {matte_only} at ({x}, {y})"
- );
- }
- for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] {
- assert_eq!(
- out.get(x as i64, y as i64).map(f32::to_bits),
- expect([0.0; 3], 0.0).map(f32::to_bits),
- "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})"
- );
- }
- }
- }
-
- #[test]
- fn cryptomatte_selects_by_name_and_wildcard() {
- let h = scene();
- let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
- assert_eq!(m.get(2, 5)[3], 0.0);
- assert_eq!(m.get(12, 5)[3], 1.0);
- let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
- assert_eq!(w.get(2, 5)[3], 1.0);
- assert_eq!(w.get(12, 5)[3], 0.0);
- assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
- let hex = format!("<{:08x}>", crypto_hash("left"));
- assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
- }
-
- /// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
- /// quoted or its comma escaped, instead of being cut into two names that match nothing.
- #[test]
- fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
- let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
- let m = Some(manifest.as_slice());
- assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
- assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
- assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
- assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
- // Without a manifest the whole name is hashed.
- assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
- // Plain lists, apostrophes inside names and an unclosed quote still parse.
- assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
- assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
- assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
- assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
- }
-
- #[test]
- fn fog_and_depth_of_field_follow_depth() {
- let h = scene();
- let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
- // Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
- assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
- assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
- assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
- assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
- assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
- assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
- // A sharp edge at the far object blurs when focused near.
- let mut pic = Image::new(20, 10);
- for y in 0..10 {
- for x in 0..20 {
- let v = if x % 2 == 0 { 1.0 } else { 0.0 };
- pic.set(x, y, [v, v, v, 1.0]);
- }
- }
- let env = EffectEnv { host: Some(&h), ..Default::default() };
- let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
- assert_eq!(d.get(4, 5), pic.get(4, 5));
- assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
- }
-
- #[test]
- fn extractor_maps_named_channels() {
- let h = scene();
- let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
- assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
- assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
- // Without aux: the layer's own channels can be swapped.
- let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
- assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
- }
-
- #[test]
- fn channel_extract_antialias_averages_sub_pixels() {
- // Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
- let mut a = AuxChannels::new(40, 20, 2.0);
- a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
- let host = AuxHost(Arc::new(a));
- let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
- let hard = run("ec.3d.channelextract", &vals, &host);
- let v = hard.get(5, 5)[0];
- assert!(v == 0.0 || v == 1.0, "{v}");
- let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
- assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
- }
-}
+mod tests;
diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs
new file mode 100644
index 000000000..4fc84440e
--- /dev/null
+++ b/crates/effects/src/channel3d/tests.rs
@@ -0,0 +1,375 @@
+use super::*;
+use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
+use effectcraft_raster::channels3d::crypto_float;
+
+/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
+/// top-right corner is background. Cryptomatte: "left" / "right".
+struct AuxHost(Arc);
+impl EffectHost for AuxHost {
+ fn layer(&self, _: u64, _: bool) -> Option {
+ None
+ }
+ fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
+ None
+ }
+ fn aux(&self) -> Option> {
+ Some(self.0.clone())
+ }
+}
+
+fn scene() -> AuxHost {
+ let (w, h) = (20usize, 10usize);
+ let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
+ let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
+ let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
+ for y in 0..h {
+ for x in 0..w {
+ let i = y * w + x;
+ if x >= 15 && y < 3 {
+ z[i] = BACKGROUND_DEPTH;
+ } else if x < 10 {
+ z[i] = 100.0;
+ id[i] = 1.0;
+ r[i] = crypto_float(hl);
+ g[i] = 1.0;
+ } else {
+ z[i] = 500.0;
+ id[i] = 2.0;
+ r[i] = crypto_float(hr);
+ g[i] = 1.0;
+ }
+ }
+ }
+ a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
+ a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
+ AuxHost(Arc::new(a))
+}
+
+fn img() -> Image {
+ Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
+}
+
+fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
+ run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
+}
+
+#[test]
+fn passes_through_without_aux_channels() {
+ for s in specs() {
+ let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
+ assert_eq!(out.img.data, img().data, "{}", s.id);
+ }
+}
+
+#[test]
+fn depth_matte_thresholds() {
+ let h = scene();
+ let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
+ assert_eq!(o.get(2, 5)[3], 0.0);
+ assert_eq!(o.get(12, 5)[3], 1.0);
+ let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
+ assert_eq!(inv.get(2, 5)[3], 1.0);
+ assert_eq!(inv.get(12, 5)[3], 0.0);
+ // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
+ let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
+ assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
+}
+
+#[test]
+fn channel_extract_maps_depth_and_ids() {
+ let h = scene();
+ let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
+ assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
+ assert_eq!(o.get(17, 1)[0], 1.0);
+ let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
+ assert_eq!(ids.get(2, 5)[0], 0.5);
+ // Invert Depth Map flips the ramp; without Clamp Output values run past 1.
+ let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h);
+ assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6);
+ let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h);
+ assert!(raw.get(12, 5)[0] > 1.5);
+}
+
+#[test]
+fn id_matte_and_identifier() {
+ let h = scene();
+ let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
+ assert_eq!(o.get(2, 5)[3], 0.0);
+ assert_eq!(o.get(12, 5)[3], 1.0);
+ let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
+ assert_eq!(l.get(2, 5)[0], 1.0);
+ assert_eq!(l.get(12, 5)[0], 0.0);
+ let c = run("ec.3d.identifier", &[], &h);
+ assert_ne!(c.get(2, 5), c.get(12, 5));
+}
+
+/// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel
+/// reduction over `crypto_ranks` it replaces.
+#[test]
+fn crypto_aux_image_matches_the_per_pixel_reduction() {
+ let (w, h) = (7u32, 5u32);
+ let n = (w * h) as usize;
+ let mut a = AuxChannels::new(w, h, 1.0);
+ let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float);
+ for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() {
+ let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect();
+ a.channels.push((format!("CryptoObject{ch}"), v));
+ }
+ let sel = vec![crypto_hash("sphere"), crypto_hash("ring")];
+ let img = crypto_aux_image(&a, "CryptoObject", &sel);
+ for i in 0..n {
+ let ranks = a.crypto_ranks("CryptoObject", i);
+ let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0);
+ let mut c = [0.0f32; 3];
+ for (id, cov) in &ranks {
+ let k = id_color(*id);
+ for j in 0..3 {
+ c[j] += k[j] * cov;
+ }
+ }
+ let want = [c[0], c[1], c[2], m];
+ assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
+ }
+}
+
+/// The lookup before #473, kept as the reference: the rank channels formatted and searched
+/// by name for every pixel.
+fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> {
+ let mut out = vec![];
+ for k in 0.. {
+ let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}"));
+ let (Some(r), Some(g)) = (get("R"), get("G")) else { break };
+ out.push((r[i], g[i]));
+ if let (Some(b), Some(al)) = (get("B"), get("A")) {
+ out.push((b[i], al[i]));
+ }
+ }
+ out
+}
+
+/// The reduction before #473 over those ranks: (ID colours, selection coverage).
+fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) {
+ let ranks = ranks_by_name(a, layer, i);
+ let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0);
+ let mut c = [0.0f32; 3];
+ for (id, cov) in &ranks {
+ let k = id_color(*id);
+ for j in 0..3 {
+ c[j] += k[j] * cov;
+ }
+ }
+ (c, m)
+}
+
+/// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN
+/// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values
+/// that would change every pixel) must be ignored as before.
+fn edge_aux(with_rank_after_gap: bool) -> AuxChannels {
+ let (w, h) = (6u32, 2u32);
+ let nan_payload = f32::from_bits(0x7fc0_1234);
+ let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube")));
+ let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5];
+ let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0];
+ let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() };
+ let mut a = AuxChannels::new(w, h, 1.0);
+ let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v));
+ push("00.R", rot(&ids, 0));
+ push("00.G", rot(&cov, 0));
+ push("00.B", rot(&ids, 3));
+ push("00.A", rot(&cov, 5));
+ push("01.R", rot(&ids, 7));
+ push("01.G", rot(&cov, 2));
+ if with_rank_after_gap {
+ push("03.R", vec![hs; 12]);
+ push("03.G", vec![1.0; 12]);
+ push("03.B", vec![hc; 12]);
+ push("03.A", vec![1.0; 12]);
+ }
+ a
+}
+
+/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values
+/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits).
+#[test]
+fn id_color_memo_matches_id_color() {
+ let a = edge_aux(true);
+ let planes = a.crypto_rank_planes("CryptoObject");
+ let mut memo = IdColorMemo::default();
+ for i in 0..12 {
+ for (r, (id, _)) in planes.iter().enumerate() {
+ assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}");
+ }
+ }
+}
+
+fn edge_selection() -> Vec {
+ vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
+}
+
+/// Edge values give bit-identical results to the lookup by name, the missing B/A pair is
+/// skipped and a rank after a gap is ignored.
+#[test]
+fn crypto_aux_image_is_bit_exact_on_edge_values() {
+ let sel = edge_selection();
+ let a = edge_aux(true);
+ assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap");
+ let img = crypto_aux_image(&a, "CryptoObject", &sel);
+ let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel);
+ for i in 0..12 {
+ let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i);
+ let want = [c[0], c[1], c[2], m];
+ assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
+ assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used");
+ }
+}
+
+/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values.
+#[test]
+fn crypto_aux_image_without_colors_keeps_the_matte() {
+ let a = edge_aux(true);
+ let sel = edge_selection();
+ let full = crypto_aux_image(&a, "CryptoObject", &sel);
+ let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly);
+ for (f, m) in full.data.iter().zip(&matte.data) {
+ assert_eq!(f[3].to_bits(), m[3].to_bits());
+ assert_eq!(m[..3], [0.0; 3]);
+ }
+}
+
+/// Only the Colors display without Matte Only reads the ID colours.
+#[test]
+fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() {
+ assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte);
+ assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly);
+ for display in [1, 2, 3, u32::MAX] {
+ for matte_only in [false, true] {
+ assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}");
+ }
+ }
+}
+
+/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
+/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
+/// get an empty matte and no ID colour.
+#[test]
+fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() {
+ let a = edge_aux(true);
+ let host = AuxHost(Arc::new(a.clone()));
+ let names = "sphere, <7fc00000>, <7fc01234>, <80000000>";
+ assert_eq!(selection_hashes(names, None), edge_selection());
+ let src = img().data[0];
+ for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] {
+ let out = run(
+ "ec.3d.cryptomatte",
+ &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))],
+ &host,
+ );
+ let expect = |c: [f32; 3], m: f32| -> [f32; 4] {
+ if matte_only {
+ return [m; 4];
+ }
+ match display {
+ 0 => {
+ let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
+ [c[0], c[1], c[2], 1.0]
+ }
+ 1 => src.map(|v| v * m),
+ _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]],
+ }
+ };
+ for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] {
+ let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize);
+ assert_eq!(out.get(x as i64, y as i64).map(f32::to_bits), expect(c, m).map(f32::to_bits), "display {display} matteOnly {matte_only} at ({x}, {y})");
+ }
+ for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] {
+ assert_eq!(
+ out.get(x as i64, y as i64).map(f32::to_bits),
+ expect([0.0; 3], 0.0).map(f32::to_bits),
+ "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})"
+ );
+ }
+ }
+}
+
+#[test]
+fn cryptomatte_selects_by_name_and_wildcard() {
+ let h = scene();
+ let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
+ assert_eq!(m.get(2, 5)[3], 0.0);
+ assert_eq!(m.get(12, 5)[3], 1.0);
+ let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
+ assert_eq!(w.get(2, 5)[3], 1.0);
+ assert_eq!(w.get(12, 5)[3], 0.0);
+ assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
+ let hex = format!("<{:08x}>", crypto_hash("left"));
+ assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
+}
+
+/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
+/// quoted or its comma escaped, instead of being cut into two names that match nothing.
+#[test]
+fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
+ let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
+ let m = Some(manifest.as_slice());
+ assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
+ assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
+ assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
+ assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
+ // Without a manifest the whole name is hashed.
+ assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
+ // Plain lists, apostrophes inside names and an unclosed quote still parse.
+ assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
+ assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
+ assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
+ assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
+}
+
+#[test]
+fn fog_and_depth_of_field_follow_depth() {
+ let h = scene();
+ let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
+ // Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
+ assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
+ assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
+ assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
+ assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
+ assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
+ assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
+ // A sharp edge at the far object blurs when focused near.
+ let mut pic = Image::new(20, 10);
+ for y in 0..10 {
+ for x in 0..20 {
+ let v = if x % 2 == 0 { 1.0 } else { 0.0 };
+ pic.set(x, y, [v, v, v, 1.0]);
+ }
+ }
+ let env = EffectEnv { host: Some(&h), ..Default::default() };
+ let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
+ assert_eq!(d.get(4, 5), pic.get(4, 5));
+ assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
+}
+
+#[test]
+fn extractor_maps_named_channels() {
+ let h = scene();
+ let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
+ assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
+ assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
+ // Without aux: the layer's own channels can be swapped.
+ let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
+ assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
+}
+
+#[test]
+fn channel_extract_antialias_averages_sub_pixels() {
+ // Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
+ let mut a = AuxChannels::new(40, 20, 2.0);
+ a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
+ let host = AuxHost(Arc::new(a));
+ let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
+ let hard = run("ec.3d.channelextract", &vals, &host);
+ let v = hard.get(5, 5)[0];
+ assert!(v == 0.0 || v == 1.0, "{v}");
+ let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
+ assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
+}
diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs
index 9547fd775..25203cd7a 100644
--- a/crates/effects/src/color2.rs
+++ b/crates/effects/src/color2.rs
@@ -1129,347 +1129,4 @@ pub fn specs() -> Vec {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{Params, find};
-
- fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image {
- let s = find(id).unwrap();
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- 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, env: Default::default() };
- (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
- }
-
- fn ramp() -> Image {
- let mut img = Image::new(16, 8);
- for y in 0..8 {
- for x in 0..16 {
- let v = x as f32 / 15.0;
- let a = if y == 0 { 0.5 } else { 1.0 };
- img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]);
- }
- }
- img
- }
-
- fn close(a: &Image, b: &Image, eps: f32) -> bool {
- a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps))
- }
-
- #[test]
- fn curve_parsing() {
- assert!(Curve::parse("0,0 1,1").is_none());
- assert!(Curve::parse("garbage").is_none());
- let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap();
- assert_eq!(c.xs, vec![0.0, 0.5, 1.0]);
- assert!((c.eval(0.5) - 0.7).abs() < 1e-3);
- // Monotone: never decreases.
- let mut last = -1.0;
- for i in 0..=100 {
- let v = c.eval(i as f32 / 100.0);
- assert!(v >= last - 1e-6);
- last = v;
- }
- }
-
- #[test]
- fn curves_identity_is_identity() {
- let img = ramp();
- assert_eq!(run("ec.color.curves", &img, &[]), img);
- let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]);
- assert!(close(&explicit, &img, 1e-4));
- }
-
- #[test]
- fn curves_midpoint_brightens() {
- let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]);
- let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]);
- assert!((out.data[0][0] - 0.75).abs() < 1e-3);
- let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]);
- assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
- }
-
- /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
- /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
- /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
- /// the channel controls run first, nothing clips (8-bit captures clamp the results).
- #[test]
- fn levels_ic_matches_after_effects_in_32_bpc() {
- let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
- let ae = [
- [0, 0, 70],
- [0, 0, 88],
- [0, 52, 104],
- [61, 108, 132],
- [102, 150, 158],
- [138, 187, 181],
- [171, 220, 203],
- [203, 250, 223],
- [233, 255, 243],
- [255, 255, 255],
- ];
- let mut img = Image::new(10, 1);
- for (x, v) in steps.iter().enumerate() {
- let v = *v as f32 / 255.0;
- img.set(x as u32, 0, [v, v, v, 1.0]);
- }
- let vals = [
- ("rgb/rgbInBlack", Value::Scalar(0.1)),
- ("rgb/rgbInWhite", Value::Scalar(0.9)),
- ("rgb/rgbGamma", Value::Scalar(1.5)),
- ("rgb/rgbOutBlack", Value::Scalar(0.05)),
- ("rgb/rgbOutWhite", Value::Scalar(0.95)),
- ("red/redGamma", Value::Scalar(0.8)),
- ("green/greenInWhite", Value::Scalar(0.8)),
- ("blue/blueOutBlack", Value::Scalar(0.2)),
- ];
- let s = find("ec.color.levelsic").unwrap();
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- for (k, v) in &vals {
- params.values.insert(k.to_string(), v.clone());
- }
- let render = |float_depth: bool| {
- let env = crate::EffectEnv { float_depth, ..Default::default() };
- let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
- (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
- };
- let out = render(true);
- for (x, want) in ae.iter().enumerate() {
- let p = out.get(x as i64, 0);
- for c in 0..3 {
- let got = p[c].clamp(0.0, 1.0) * 255.0;
- assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
- }
- }
- // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
- let clipped = render(false);
- assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
- }
-
- /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
- /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
- #[test]
- fn curves_follow_a_natural_spline_as_in_after_effects() {
- let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
- let mut img = Image::new(10, 1);
- for (x, v) in steps.iter().enumerate() {
- let v = *v as f32 / 255.0;
- img.set(x as u32, 0, [v, v, v, 1.0]);
- }
- let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" "));
- let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
- let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
- let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
- let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
- for x in 0..10 {
- let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
- let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
- assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
- assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
- }
- }
-
- #[test]
- fn levels_ic_identity_and_red_only() {
- let img = ramp();
- assert_eq!(run("ec.color.levelsic", &img, &[]), img);
- let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]);
- let p = out.get(15, 3);
- assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4);
- }
-
- #[test]
- fn auto_levels_stretches_low_contrast() {
- let mut img = Image::new(32, 4);
- for y in 0..4 {
- for x in 0..32 {
- let v = 0.4 + 0.2 * x as f32 / 31.0;
- img.set(x, y, [v, v, v, 1.0]);
- }
- }
- let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
- let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min);
- let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max);
- assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}");
- let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
- assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98);
- }
-
- #[test]
- fn auto_color_neutralises_cast() {
- let mut img = Image::new(16, 4);
- for y in 0..4 {
- for x in 0..16 {
- let v = x as f32 / 15.0;
- img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]);
- }
- }
- let out = run("ec.color.autocolor", &img, &[]);
- let p = out.get(8, 1);
- assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}");
- }
-
- #[test]
- fn equalize_flattens_histogram() {
- let mut img = Image::new(64, 1);
- for x in 0..64 {
- let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 };
- img.set(x, 0, [v, v, v, 1.0]);
- }
- let out = run("ec.color.equalize", &img, &[]);
- // The crowded dark values are spread across the range.
- assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]);
- assert!(out.get(0, 0)[0] < 0.1);
- assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img);
- }
-
- #[test]
- fn selective_color_zero_is_identity_and_reds_respond() {
- let img = ramp();
- assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
- let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
- let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
- assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
- let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
- assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
- }
-
- #[test]
- fn color_offset_full_turn_is_identity() {
- let img = ramp();
- let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]);
- assert_eq!(out, img);
- let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]);
- assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4);
- }
-
- #[test]
- fn broadcast_reduces_unsafe_color() {
- let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]);
- let out = run("ec.color.broadcast", &img, &[]);
- assert!(out.data[0][0] < 1.0);
- let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]);
- assert_eq!(key.data[0][3], 0.0);
- let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
- assert_eq!(run("ec.color.broadcast", &gray, &[]), gray);
- }
-
- #[test]
- fn color_balance_hls_zero_identity_and_hue_shift() {
- let img = ramp();
- assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img);
- let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]);
- let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]);
- assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01);
- }
-
- #[test]
- fn color_link_fills_with_average_keeping_alpha() {
- let mut img = Image::new(2, 1);
- img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
- img.set(1, 0, [0.0, 0.0, 0.5, 0.5]);
- let out = run("ec.color.colorlink", &img, &[]);
- assert!((out.data[1][3] - 0.5).abs() < 1e-5);
- let (c, _) = unpremul(out.data[0]);
- assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
- }
-
- #[test]
- fn cc_kernel_identity_and_toner_duotone() {
- let img = ramp();
- assert_eq!(run("ec.color.cckernel", &img, &[]), img);
- let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]);
- let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]);
- assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3);
- }
-
- #[test]
- fn shadow_highlight_lifts_shadows() {
- let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]);
- let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]);
- assert!(out.data[0][0] > 0.15);
- }
-
- #[test]
- fn levels_ic_clip_popups() {
- let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]);
- let lv = [("rgb/rgbInWhite", num(0.5))];
- assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6);
- let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))];
- assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5);
- }
-
- /// A host whose other layers and other frames are a fixed image.
- struct ImgHost(Image);
- impl crate::EffectHost for ImgHost {
- fn layer(&self, _: u64, _: bool) -> Option {
- Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] })
- }
- fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
- None
- }
- fn self_at(&self, _: f64, _: usize) -> Option {
- Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 })
- }
- }
-
- fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image {
- let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
- crate::run_fx(id, over, img.clone(), 1.0, env).img
- }
-
- #[test]
- fn color_link_samples_clip_and_source_layer() {
- let mut img = Image::new(5, 1);
- for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() {
- img.set(x as u32, 0, [*v, *v, *v, 1.0]);
- }
- // Median ignores the outlier the average follows.
- let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]);
- assert!((med.data[0][0] - 0.2).abs() < 1e-5);
- // Brightest with 20 % clip skips the brightest pixel.
- let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]);
- assert!((br.data[0][0] - 0.3).abs() < 1e-5);
- // Min Alpha of a half-transparent source layer sets the alpha.
- let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5]));
- let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host);
- assert!((out.data[4][3] - 0.5).abs() < 1e-5);
- // Average of the source layer (not the effect's own pixels).
- let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host);
- assert!((out.data[4][0] - 0.5).abs() < 1e-5);
- }
-
- #[test]
- fn auto_levels_temporal_smoothing_reads_neighbour_frames() {
- let mut img = Image::new(4, 1);
- for x in 0..4 {
- let v = 0.3 + 0.1 * x as f32;
- img.set(x, 0, [v, v, v, 1.0]);
- }
- let wide = ImgHost(ramp());
- let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))];
- let alone = run_host("ec.color.autolevels", &img, &p0, &wide);
- let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide);
- // Alone the frame is stretched to full range; with full-range neighbours much less.
- assert!(alone.data[0][0] < 0.01);
- assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]);
- }
-
- #[test]
- fn shadow_highlight_clip_keeps_the_tonal_range() {
- let img = ramp();
- let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))];
- let unclipped = run(
- "ec.color.shadowhighlight",
- &img,
- &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))],
- );
- let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]);
- let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min);
- assert!(min(&clipped) < min(&unclipped));
- }
-}
+mod tests;
diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs
new file mode 100644
index 000000000..5f57692a0
--- /dev/null
+++ b/crates/effects/src/color2/tests.rs
@@ -0,0 +1,338 @@
+use super::*;
+use crate::{Params, find};
+
+fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image {
+ let s = find(id).unwrap();
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ 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, env: Default::default() };
+ (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
+}
+
+fn ramp() -> Image {
+ let mut img = Image::new(16, 8);
+ for y in 0..8 {
+ for x in 0..16 {
+ let v = x as f32 / 15.0;
+ let a = if y == 0 { 0.5 } else { 1.0 };
+ img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]);
+ }
+ }
+ img
+}
+
+fn close(a: &Image, b: &Image, eps: f32) -> bool {
+ a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps))
+}
+
+#[test]
+fn curve_parsing() {
+ assert!(Curve::parse("0,0 1,1").is_none());
+ assert!(Curve::parse("garbage").is_none());
+ let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap();
+ assert_eq!(c.xs, vec![0.0, 0.5, 1.0]);
+ assert!((c.eval(0.5) - 0.7).abs() < 1e-3);
+ // Monotone: never decreases.
+ let mut last = -1.0;
+ for i in 0..=100 {
+ let v = c.eval(i as f32 / 100.0);
+ assert!(v >= last - 1e-6);
+ last = v;
+ }
+}
+
+#[test]
+fn curves_identity_is_identity() {
+ let img = ramp();
+ assert_eq!(run("ec.color.curves", &img, &[]), img);
+ let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]);
+ assert!(close(&explicit, &img, 1e-4));
+}
+
+#[test]
+fn curves_midpoint_brightens() {
+ let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]);
+ let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]);
+ assert!((out.data[0][0] - 0.75).abs() < 1e-3);
+ let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]);
+ assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
+}
+
+/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
+/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
+/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
+/// the channel controls run first, nothing clips (8-bit captures clamp the results).
+#[test]
+fn levels_ic_matches_after_effects_in_32_bpc() {
+ let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
+ let ae = [
+ [0, 0, 70],
+ [0, 0, 88],
+ [0, 52, 104],
+ [61, 108, 132],
+ [102, 150, 158],
+ [138, 187, 181],
+ [171, 220, 203],
+ [203, 250, 223],
+ [233, 255, 243],
+ [255, 255, 255],
+ ];
+ let mut img = Image::new(10, 1);
+ for (x, v) in steps.iter().enumerate() {
+ let v = *v as f32 / 255.0;
+ img.set(x as u32, 0, [v, v, v, 1.0]);
+ }
+ let vals = [
+ ("rgb/rgbInBlack", Value::Scalar(0.1)),
+ ("rgb/rgbInWhite", Value::Scalar(0.9)),
+ ("rgb/rgbGamma", Value::Scalar(1.5)),
+ ("rgb/rgbOutBlack", Value::Scalar(0.05)),
+ ("rgb/rgbOutWhite", Value::Scalar(0.95)),
+ ("red/redGamma", Value::Scalar(0.8)),
+ ("green/greenInWhite", Value::Scalar(0.8)),
+ ("blue/blueOutBlack", Value::Scalar(0.2)),
+ ];
+ let s = find("ec.color.levelsic").unwrap();
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ for (k, v) in &vals {
+ params.values.insert(k.to_string(), v.clone());
+ }
+ let render = |float_depth: bool| {
+ let env = crate::EffectEnv { float_depth, ..Default::default() };
+ let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
+ (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
+ };
+ let out = render(true);
+ for (x, want) in ae.iter().enumerate() {
+ let p = out.get(x as i64, 0);
+ for c in 0..3 {
+ let got = p[c].clamp(0.0, 1.0) * 255.0;
+ assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
+ }
+ }
+ // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
+ let clipped = render(false);
+ assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
+}
+
+/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
+/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
+#[test]
+fn curves_follow_a_natural_spline_as_in_after_effects() {
+ let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
+ let mut img = Image::new(10, 1);
+ for (x, v) in steps.iter().enumerate() {
+ let v = *v as f32 / 255.0;
+ img.set(x as u32, 0, [v, v, v, 1.0]);
+ }
+ let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" "));
+ let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
+ let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
+ let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
+ let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
+ for x in 0..10 {
+ let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
+ let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
+ assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
+ assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
+ }
+}
+
+#[test]
+fn levels_ic_identity_and_red_only() {
+ let img = ramp();
+ assert_eq!(run("ec.color.levelsic", &img, &[]), img);
+ let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]);
+ let p = out.get(15, 3);
+ assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4);
+}
+
+#[test]
+fn auto_levels_stretches_low_contrast() {
+ let mut img = Image::new(32, 4);
+ for y in 0..4 {
+ for x in 0..32 {
+ let v = 0.4 + 0.2 * x as f32 / 31.0;
+ img.set(x, y, [v, v, v, 1.0]);
+ }
+ }
+ let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
+ let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min);
+ let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max);
+ assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}");
+ let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
+ assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98);
+}
+
+#[test]
+fn auto_color_neutralises_cast() {
+ let mut img = Image::new(16, 4);
+ for y in 0..4 {
+ for x in 0..16 {
+ let v = x as f32 / 15.0;
+ img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]);
+ }
+ }
+ let out = run("ec.color.autocolor", &img, &[]);
+ let p = out.get(8, 1);
+ assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}");
+}
+
+#[test]
+fn equalize_flattens_histogram() {
+ let mut img = Image::new(64, 1);
+ for x in 0..64 {
+ let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 };
+ img.set(x, 0, [v, v, v, 1.0]);
+ }
+ let out = run("ec.color.equalize", &img, &[]);
+ // The crowded dark values are spread across the range.
+ assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]);
+ assert!(out.get(0, 0)[0] < 0.1);
+ assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img);
+}
+
+#[test]
+fn selective_color_zero_is_identity_and_reds_respond() {
+ let img = ramp();
+ assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
+ let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
+ let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
+ assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
+ let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
+ assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
+}
+
+#[test]
+fn color_offset_full_turn_is_identity() {
+ let img = ramp();
+ let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]);
+ assert_eq!(out, img);
+ let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]);
+ assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4);
+}
+
+#[test]
+fn broadcast_reduces_unsafe_color() {
+ let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]);
+ let out = run("ec.color.broadcast", &img, &[]);
+ assert!(out.data[0][0] < 1.0);
+ let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]);
+ assert_eq!(key.data[0][3], 0.0);
+ let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
+ assert_eq!(run("ec.color.broadcast", &gray, &[]), gray);
+}
+
+#[test]
+fn color_balance_hls_zero_identity_and_hue_shift() {
+ let img = ramp();
+ assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img);
+ let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]);
+ let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]);
+ assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01);
+}
+
+#[test]
+fn color_link_fills_with_average_keeping_alpha() {
+ let mut img = Image::new(2, 1);
+ img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
+ img.set(1, 0, [0.0, 0.0, 0.5, 0.5]);
+ let out = run("ec.color.colorlink", &img, &[]);
+ assert!((out.data[1][3] - 0.5).abs() < 1e-5);
+ let (c, _) = unpremul(out.data[0]);
+ assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
+}
+
+#[test]
+fn cc_kernel_identity_and_toner_duotone() {
+ let img = ramp();
+ assert_eq!(run("ec.color.cckernel", &img, &[]), img);
+ let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]);
+ let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]);
+ assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3);
+}
+
+#[test]
+fn shadow_highlight_lifts_shadows() {
+ let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]);
+ let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]);
+ assert!(out.data[0][0] > 0.15);
+}
+
+#[test]
+fn levels_ic_clip_popups() {
+ let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]);
+ let lv = [("rgb/rgbInWhite", num(0.5))];
+ assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6);
+ let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))];
+ assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5);
+}
+
+/// A host whose other layers and other frames are a fixed image.
+struct ImgHost(Image);
+impl crate::EffectHost for ImgHost {
+ fn layer(&self, _: u64, _: bool) -> Option {
+ Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] })
+ }
+ fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
+ None
+ }
+ fn self_at(&self, _: f64, _: usize) -> Option {
+ Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 })
+ }
+}
+
+fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image {
+ let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
+ crate::run_fx(id, over, img.clone(), 1.0, env).img
+}
+
+#[test]
+fn color_link_samples_clip_and_source_layer() {
+ let mut img = Image::new(5, 1);
+ for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() {
+ img.set(x as u32, 0, [*v, *v, *v, 1.0]);
+ }
+ // Median ignores the outlier the average follows.
+ let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]);
+ assert!((med.data[0][0] - 0.2).abs() < 1e-5);
+ // Brightest with 20 % clip skips the brightest pixel.
+ let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]);
+ assert!((br.data[0][0] - 0.3).abs() < 1e-5);
+ // Min Alpha of a half-transparent source layer sets the alpha.
+ let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5]));
+ let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host);
+ assert!((out.data[4][3] - 0.5).abs() < 1e-5);
+ // Average of the source layer (not the effect's own pixels).
+ let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host);
+ assert!((out.data[4][0] - 0.5).abs() < 1e-5);
+}
+
+#[test]
+fn auto_levels_temporal_smoothing_reads_neighbour_frames() {
+ let mut img = Image::new(4, 1);
+ for x in 0..4 {
+ let v = 0.3 + 0.1 * x as f32;
+ img.set(x, 0, [v, v, v, 1.0]);
+ }
+ let wide = ImgHost(ramp());
+ let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))];
+ let alone = run_host("ec.color.autolevels", &img, &p0, &wide);
+ let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide);
+ // Alone the frame is stretched to full range; with full-range neighbours much less.
+ assert!(alone.data[0][0] < 0.01);
+ assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]);
+}
+
+#[test]
+fn shadow_highlight_clip_keeps_the_tonal_range() {
+ let img = ramp();
+ let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))];
+ let unclipped =
+ run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))]);
+ let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]);
+ let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min);
+ assert!(min(&clipped) < min(&unclipped));
+}
diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs
index 402d0ebe2..3a52e7a54 100644
--- a/crates/effects/src/color_fx.rs
+++ b/crates/effects/src/color_fx.rs
@@ -25,7 +25,7 @@ pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
}
-/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU
+/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
@@ -384,7 +384,7 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
-/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU
+/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
@@ -755,7 +755,7 @@ impl ColoramaPalette {
}
}
- /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
+ /// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
pub fn parse(s: &str) -> Option {
let mut stops: Vec<(f32, [f32; 4])> = s
.split_whitespace()
@@ -1215,524 +1215,4 @@ pub fn specs() -> Vec {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{EffectEnv, run_fx};
- use effectcraft_raster::Image;
-
- fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] {
- run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0)
- }
- fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
- a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
- }
-
- /// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before.
- #[test]
- fn old_saves_get_legacy_values_for_added_params() {
- use effectcraft_project::build::Ids;
- let mut next = 1;
- for (id, param, want) in
- [("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))]
- {
- let spec = crate::find(id).unwrap();
- let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
- g.children.retain(|c| c.match_id() != param);
- crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
- assert_eq!(g.get(param).unwrap().value, want, "{id}");
- }
- }
-
- /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
- /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
- #[test]
- fn tint_defaults_match_after_effects_greys() {
- for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
- let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
- let want = want / 255.0;
- assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
- }
- }
-
- #[test]
- fn colorama_reads_get_phase_from() {
- // Same intensity, different red: Intensity gives one colour, Red gives two.
- let a = [0.6, 0.2, 0.2, 1.0];
- let b = [0.2, 0.2, 0.6, 1.0];
- let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c);
- assert!(!close(red(a), red(b), 1e-3));
- // Zero maps everything to the start of the cycle (red).
- assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5));
- // Interpolate Palette off posterizes: two nearby inputs land on the same colour.
- let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]);
- assert_eq!(off(0.20), off(0.22));
- }
-
- #[test]
- fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() {
- let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0];
- let up = [("brightness", num(50.0)), ("contrast", num(40.0))];
- assert_eq!(bc(&up, 0.0), 0.0);
- assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6);
- assert!(bc(&up, 0.4) > 0.4);
- let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))];
- assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5);
- assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
- }
-
- /// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
- #[test]
- fn brightness_contrast_modern_matches_ae_ramp_samples() {
- let u8 = |v: f32| (v * 255.0).round() as i32;
- let go = |b: f64, c: f64, x: u8| {
- u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
- [0])
- };
- // (brightness, contrast, input, AE 8-bit)
- let samples: &[(f64, f64, u8, u8)] = &[
- (0.0, 100.0, 64, 40),
- (0.0, 100.0, 128, 129),
- (0.0, 100.0, 192, 217),
- (0.0, 50.0, 64, 53),
- (0.0, 50.0, 192, 205),
- (0.0, -50.0, 64, 77),
- (100.0, 0.0, 128, 209),
- (50.0, 0.0, 128, 171),
- (106.0, -18.0, 128, 214),
- ];
- for &(b, c, x, ae) in samples {
- let got = go(b, c, x);
- assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
- }
- // Old g=4 S-curve put Contrast 100 at ~8/255 here.
- assert!(go(0.0, 100.0, 64) >= 30);
- }
-
- /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
- /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
- /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
- /// (153, 51, 51), (204, 153, 128).
- #[test]
- fn tritone_matches_after_effects() {
- let colours = [
- ("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])),
- ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])),
- ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])),
- ];
- let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
- let mut red = colours.to_vec();
- red.push(("blend", num(25.0)));
- let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
- assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
- for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
- let v = level / 255.0;
- let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
- assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
- }
- }
-
- /// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
- /// and white solids at the default sliders (case 825).
- #[test]
- fn black_white_tint_matches_after_effects() {
- let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
- let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
- let mut reds = tint.to_vec();
- reds.push(("reds", num(10.0)));
- let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
- assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
- let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
- assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
- let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
- assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
- // Hostile grays stay finite.
- for g in [f32::NAN, -1.0, 0.0, 4.0] {
- assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
- }
- }
-
- /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
- /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
- #[test]
- fn negative_brightness_and_contrast_invert_the_positive_curves() {
- let go = |b: f64, c: f64, x: u8| {
- let v = x as f32 / 255.0;
- (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
- };
- let samples: &[(f64, f64, u8, u8)] = &[
- (-40.0, 0.0, 64, 50),
- (-40.0, 0.0, 128, 99),
- (-40.0, 0.0, 191, 155),
- (-40.0, 0.0, 234, 207),
- (0.0, -50.0, 21, 28),
- (0.0, -50.0, 64, 76),
- (0.0, -50.0, 191, 179),
- (0.0, -50.0, 234, 227),
- (40.0, 0.0, 128, 162),
- (0.0, 50.0, 64, 52),
- ];
- for &(b, c, x, ae) in samples {
- let got = go(b, c, x);
- assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
- }
- }
-
- /// #1463: Black & White's sliders set the gray of each pure hue and blend between
- /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
- /// colour stays finite (it used to divide by zero).
- #[test]
- fn black_white_sliders_set_the_gray_of_each_hue() {
- let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
- let gray = |v: f32| [v, v, v, 1.0];
- // The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
- let hues = [
- ([1.0, 0.0, 0.0], 0.4),
- ([1.0, 1.0, 0.0], 0.6),
- ([0.0, 1.0, 0.0], 0.4),
- ([0.0, 1.0, 1.0], 0.6),
- ([0.0, 0.0, 1.0], 0.2),
- ([1.0, 0.0, 1.0], 0.8),
- // Half as bright, half the gray; orange is halfway from Reds to Yellows.
- ([0.5, 0.0, 0.0], 0.2),
- ([1.0, 0.5, 0.0], 0.5),
- // Neutral pixels keep their value.
- ([0.3, 0.3, 0.3], 0.3),
- ([4.0, 4.0, 4.0], 4.0),
- // An overbright red: its smallest channel plus its chroma at Reds' 40 %.
- ([1.5, 0.5, 0.5], 0.9),
- ];
- for (c, want) in hues {
- let o = bw(&[], c);
- assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
- }
- assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
- assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
- assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
- }
-
- /// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
- /// two of colour noise, which Black & White turned into bright and black blocks (the HSL
- /// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
- /// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
- #[test]
- fn black_white_keeps_faint_colour_noise_faint() {
- // A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
- for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
- let (w, h) = (64u32, 4u32);
- let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
- for y in 0..h {
- for x in 0..w {
- // A dark ramp up to a quarter gray, in whole steps...
- let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
- // ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
- let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
- clean.set(x, y, [v, v, v, 1.0]);
- noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
- }
- }
- let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
- let (a, b) = (bw(clean), bw(noisy));
- let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
- assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
- }
- }
-
- #[test]
- fn levels_clips_black_and_white_independently() {
- let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
- let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))];
- assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5);
- let mut no_white = base.to_vec();
- no_white.push(("clipToOutputWhite", Value::Enum(0)));
- assert!(lv(&no_white, 0.9) > 0.9);
- assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5);
- // The hidden switch of older projects turns both off.
- let mut legacy = base.to_vec();
- legacy.push(("noClip", Value::Bool(true)));
- assert!(lv(&legacy, 0.9) > 0.9);
- }
-
- #[test]
- fn exposure_individual_channels_and_bypass() {
- let g = [0.5, 0.5, 0.5, 1.0];
- let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g);
- assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5);
- // Master values are ignored in Individual Channels mode.
- assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5));
- // Bypass Linear Light Conversion: +1 stop doubles the raw value.
- let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]);
- assert!((raw[0] - 0.5).abs() < 1e-5);
- // Negative results are mirrored, not clipped.
- let neg = px(
- "ec.color.exposure",
- &[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))],
- [0.25, 0.25, 0.25, 1.0],
- );
- assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
- }
-
- /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
- /// of 0.05, valid before, as in Photoshop) survives loading and still renders.
- #[test]
- fn exposure_has_after_effects_ranges_and_keeps_old_values() {
- use effectcraft_project::build::Ids;
- let spec = crate::find("ec.color.exposure").unwrap();
- let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
- let sliders: Vec<_> = spec
- .params
- .iter()
- .filter_map(|p| match p.ui {
- ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
- _ => None,
- })
- .collect();
- assert_eq!(sliders.len(), 12);
- for (i, (id, r)) in sliders.iter().enumerate() {
- assert_eq!(*r, want[i % 3], "{id}");
- }
- let mut next = 1;
- let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
- let gamma = g.prop_mut("master/gamma").unwrap();
- gamma.value = num(0.05);
- gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
- crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
- let gamma = g.prop_mut("master/gamma").unwrap();
- assert_eq!(gamma.value, num(0.05));
- assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
- let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
- assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
- }
-
- /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
- /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
- /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
- #[test]
- fn exposure_matches_after_effects_gamma_2_2() {
- let red = [1.0, 0.0, 0.0, 1.0];
- let code = |o: [f32; 4], i: usize| o[i] * 255.0;
- // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
- for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
- let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
- for i in [1, 2] {
- assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
- }
- assert!(o[0] >= 1.0, "{o:?}");
- }
- // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
- let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
- assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
- let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
- assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
- // Default settings leave 32-bpc values alone, negative and over-range ones included.
- let hdr = [-0.25, 0.5, 2.0, 1.0];
- assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
- // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
- let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
- assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
- }
-
- #[test]
- fn change_to_color_change_modes_tolerances_and_matte() {
- let red = [0.8, 0.1, 0.1, 1.0];
- let green = [0.1, 0.8, 0.1, 1.0];
- let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))];
- // Hue only: red turns blue with its own lightness / saturation; green is untouched.
- let o = px("ec.color.changetocolor", &base, red);
- assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}");
- assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5));
- // The matte is white where matched, black elsewhere.
- let mut m = base.to_vec();
- m.push(("viewCorrectionMatte", Value::Bool(true)));
- assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5));
- assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5));
- // A lightness tolerance of 0 rejects a lighter red.
- let mut strict = m.clone();
- strict.push(("toleranceGroup/lightness", num(0.0)));
- strict.push(("softness", num(0.0)));
- assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5));
- // Hue, Lightness & Saturation, set: a matched pixel becomes the To colour.
- let mut all = base.to_vec();
- all.push(("change", Value::Enum(3)));
- assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4));
- }
-
- #[test]
- fn leave_color_tolerance_softness_and_match_mode() {
- let green = [0.1, 0.8, 0.1, 1.0];
- let lc = |vals: &[(&str, Value)]| {
- let mut v = vec![("amount", num(100.0))];
- v.extend_from_slice(vals);
- px("ec.color.leavecolor", &v, green)
- };
- let g = luminance(0.1, 0.8, 0.1);
- assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5));
- assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6));
- // Softness keeps part of the colour.
- let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]);
- assert!(s[1] > g + 0.05 && s[1] < 0.8);
- // Using Hue: a dark red still matches pure red.
- let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]);
- assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6));
- }
-
- #[test]
- fn photo_filter_presets_and_custom() {
- let g = [0.5, 0.5, 0.5, 1.0];
- let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g);
- assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}");
- let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g);
- assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]);
- // The Color swatch is ignored by the presets.
- let warm = |c| px("ec.color.photofilter", &[("color", c)], g);
- assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0)));
- }
-
- #[test]
- fn color_balance_preserve_luminosity_and_black_stretch() {
- let g = [0.5, 0.5, 0.5, 1.0];
- let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g);
- assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]);
- let dark = [0.1, 0.1, 0.1, 1.0];
- assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
- assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
- }
-
- #[test]
- fn levels_channel_controls_act_on_their_channel() {
- let g = [0.5, 0.5, 0.5, 1.0];
- assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
- let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
- assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
- let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
- assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
- let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
- assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
- // Per-channel clipping follows its own popups.
- let hot = [0.9, 0.9, 0.9, 1.0];
- let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
- let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
- assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
- }
-
- #[test]
- fn hue_saturation_ranges_adjust_their_hues_only() {
- let red = [0.8, 0.1, 0.1, 1.0];
- let blue = [0.1, 0.1, 0.8, 1.0];
- let grey = [0.5, 0.5, 0.5, 1.0];
- let v = [("redsSaturation", num(-100.0))];
- let r = px("ec.color.huesaturation", &v, red);
- assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
- assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
- assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
- // Blues hue +120°: blue becomes red.
- let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
- assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
- // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
- let orange_ish = {
- let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
- [r, g, b, 1.0]
- };
- let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
- let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
- assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
- // Moving the range changes which hues it takes.
- let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
- assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
- let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
- let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
- assert!(!huesat_ranges_identity(&ctx));
- }
-
- #[test]
- fn fill_mask_all_masks_feather_and_invert() {
- let sq = |x0: f64, x1: f64| crate::MaskShape {
- name: String::new(),
- points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]],
- closed: true,
- inverted: false,
- };
- let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
- let env = EffectEnv { masks: &masks, ..Default::default() };
- let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
- let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
- let one = run(&[("fillMask", num(1.0))]);
- assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
- assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
- assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
- let all = run(&[("allMasks", Value::Bool(true))]);
- assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
- let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
- assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
- assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
- let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
- let v = soft.get(6, 5)[1];
- assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
- // Without masks, Fill fills the layer as before.
- let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
- assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
- }
-
- /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
- /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
- /// fill colour at half the layer's alpha, nothing of the ramp left.
- #[test]
- fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
- let mut img = Image::new(16, 4);
- for x in 0..16 {
- let v = x as f32 / 15.0;
- for y in 0..4 {
- img.set(x, y, [v, 0.0, 0.0, 1.0]);
- }
- }
- let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
- let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
- for x in [0, 7, 15] {
- let p = out.get(x, 2);
- let ae = [0.0, 0.251, 0.502, 0.502];
- assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
- }
- // A half-transparent pixel ends at a quarter alpha, still the fill colour.
- let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
- assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
- }
-
- #[test]
- fn colorama_palettes_modify_selection_masking_and_add_phase() {
- let grey = [0.5, 0.5, 0.5, 1.0];
- let red = [1.0, 0.0, 0.0, 1.0];
- // Ramp Grayscale: intensity 0.5 maps to mid grey.
- let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
- assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
- // Custom palette: two stops, black at 0 and blue at 0.5.
- let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
- let c = px("ec.color.colorama", &custom, grey);
- assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
- // Modify Lightness keeps the hue of the pixel (red stays red).
- let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
- assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
- // Pixel Selection: only colours near Matching Color change.
- let sel = [
- ("pixelSelection/matchingMode", Value::Enum(1)),
- ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
- ("pixelSelection/matchingTolerance", num(10.0)),
- ];
- assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
- assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
- // Composite Over Layer off: unselected pixels vanish.
- let mut off = sel.to_vec();
- off.push(("masking/compositeOverLayer", Value::Bool(false)));
- assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
- // Modify Alpha takes the palette alpha.
- let alpha = [
- ("outputCycle/usePresetPalette", Value::Enum(8)),
- ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
- ("modify/modifyAlpha", Value::Bool(true)),
- ];
- assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
- // Palette parsing and evaluation.
- let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
- assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
- assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
- assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
- }
-}
+mod tests;
diff --git a/crates/effects/src/color_fx/tests.rs b/crates/effects/src/color_fx/tests.rs
new file mode 100644
index 000000000..2f3370204
--- /dev/null
+++ b/crates/effects/src/color_fx/tests.rs
@@ -0,0 +1,508 @@
+use super::*;
+use crate::{EffectEnv, run_fx};
+use effectcraft_raster::Image;
+
+fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] {
+ run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0)
+}
+fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
+ a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
+}
+
+/// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before.
+#[test]
+fn old_saves_get_legacy_values_for_added_params() {
+ use effectcraft_project::build::Ids;
+ let mut next = 1;
+ for (id, param, want) in
+ [("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))]
+ {
+ let spec = crate::find(id).unwrap();
+ let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
+ g.children.retain(|c| c.match_id() != param);
+ crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
+ assert_eq!(g.get(param).unwrap().value, want, "{id}");
+ }
+}
+
+/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
+/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
+#[test]
+fn tint_defaults_match_after_effects_greys() {
+ for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
+ let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
+ let want = want / 255.0;
+ assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
+ }
+}
+
+#[test]
+fn colorama_reads_get_phase_from() {
+ // Same intensity, different red: Intensity gives one colour, Red gives two.
+ let a = [0.6, 0.2, 0.2, 1.0];
+ let b = [0.2, 0.2, 0.6, 1.0];
+ let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c);
+ assert!(!close(red(a), red(b), 1e-3));
+ // Zero maps everything to the start of the cycle (red).
+ assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5));
+ // Interpolate Palette off posterizes: two nearby inputs land on the same colour.
+ let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]);
+ assert_eq!(off(0.20), off(0.22));
+}
+
+#[test]
+fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() {
+ let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0];
+ let up = [("brightness", num(50.0)), ("contrast", num(40.0))];
+ assert_eq!(bc(&up, 0.0), 0.0);
+ assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6);
+ assert!(bc(&up, 0.4) > 0.4);
+ let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))];
+ assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5);
+ assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
+}
+
+/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
+#[test]
+fn brightness_contrast_modern_matches_ae_ramp_samples() {
+ let u8 = |v: f32| (v * 255.0).round() as i32;
+ let go = |b: f64, c: f64, x: u8| {
+ u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])[0])
+ };
+ // (brightness, contrast, input, AE 8-bit)
+ let samples: &[(f64, f64, u8, u8)] = &[
+ (0.0, 100.0, 64, 40),
+ (0.0, 100.0, 128, 129),
+ (0.0, 100.0, 192, 217),
+ (0.0, 50.0, 64, 53),
+ (0.0, 50.0, 192, 205),
+ (0.0, -50.0, 64, 77),
+ (100.0, 0.0, 128, 209),
+ (50.0, 0.0, 128, 171),
+ (106.0, -18.0, 128, 214),
+ ];
+ for &(b, c, x, ae) in samples {
+ let got = go(b, c, x);
+ assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
+ }
+ // Old g=4 S-curve put Contrast 100 at ~8/255 here.
+ assert!(go(0.0, 100.0, 64) >= 30);
+}
+
+/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
+/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
+/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
+/// (153, 51, 51), (204, 153, 128).
+#[test]
+fn tritone_matches_after_effects() {
+ let colours =
+ [("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0]))];
+ let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
+ let mut red = colours.to_vec();
+ red.push(("blend", num(25.0)));
+ let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
+ assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
+ for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
+ let v = level / 255.0;
+ let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
+ assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
+ }
+}
+
+/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
+/// and white solids at the default sliders (case 825).
+#[test]
+fn black_white_tint_matches_after_effects() {
+ let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
+ let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
+ let mut reds = tint.to_vec();
+ reds.push(("reds", num(10.0)));
+ let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
+ assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
+ let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
+ assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
+ let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
+ assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
+ // Hostile grays stay finite.
+ for g in [f32::NAN, -1.0, 0.0, 4.0] {
+ assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
+ }
+}
+
+/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
+/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
+#[test]
+fn negative_brightness_and_contrast_invert_the_positive_curves() {
+ let go = |b: f64, c: f64, x: u8| {
+ let v = x as f32 / 255.0;
+ (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
+ };
+ let samples: &[(f64, f64, u8, u8)] = &[
+ (-40.0, 0.0, 64, 50),
+ (-40.0, 0.0, 128, 99),
+ (-40.0, 0.0, 191, 155),
+ (-40.0, 0.0, 234, 207),
+ (0.0, -50.0, 21, 28),
+ (0.0, -50.0, 64, 76),
+ (0.0, -50.0, 191, 179),
+ (0.0, -50.0, 234, 227),
+ (40.0, 0.0, 128, 162),
+ (0.0, 50.0, 64, 52),
+ ];
+ for &(b, c, x, ae) in samples {
+ let got = go(b, c, x);
+ assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
+ }
+}
+
+/// #1463: Black & White's sliders set the gray of each pure hue and blend between
+/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
+/// colour stays finite (it used to divide by zero).
+#[test]
+fn black_white_sliders_set_the_gray_of_each_hue() {
+ let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
+ let gray = |v: f32| [v, v, v, 1.0];
+ // The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
+ let hues = [
+ ([1.0, 0.0, 0.0], 0.4),
+ ([1.0, 1.0, 0.0], 0.6),
+ ([0.0, 1.0, 0.0], 0.4),
+ ([0.0, 1.0, 1.0], 0.6),
+ ([0.0, 0.0, 1.0], 0.2),
+ ([1.0, 0.0, 1.0], 0.8),
+ // Half as bright, half the gray; orange is halfway from Reds to Yellows.
+ ([0.5, 0.0, 0.0], 0.2),
+ ([1.0, 0.5, 0.0], 0.5),
+ // Neutral pixels keep their value.
+ ([0.3, 0.3, 0.3], 0.3),
+ ([4.0, 4.0, 4.0], 4.0),
+ // An overbright red: its smallest channel plus its chroma at Reds' 40 %.
+ ([1.5, 0.5, 0.5], 0.9),
+ ];
+ for (c, want) in hues {
+ let o = bw(&[], c);
+ assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
+ }
+ assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
+ assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
+ assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
+}
+
+/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
+/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
+/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
+/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
+#[test]
+fn black_white_keeps_faint_colour_noise_faint() {
+ // A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
+ for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
+ let (w, h) = (64u32, 4u32);
+ let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
+ for y in 0..h {
+ for x in 0..w {
+ // A dark ramp up to a quarter gray, in whole steps...
+ let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
+ // ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
+ let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
+ clean.set(x, y, [v, v, v, 1.0]);
+ noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
+ }
+ }
+ let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
+ let (a, b) = (bw(clean), bw(noisy));
+ let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
+ assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
+ }
+}
+
+#[test]
+fn levels_clips_black_and_white_independently() {
+ let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
+ let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))];
+ assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5);
+ let mut no_white = base.to_vec();
+ no_white.push(("clipToOutputWhite", Value::Enum(0)));
+ assert!(lv(&no_white, 0.9) > 0.9);
+ assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5);
+ // The hidden switch of older projects turns both off.
+ let mut legacy = base.to_vec();
+ legacy.push(("noClip", Value::Bool(true)));
+ assert!(lv(&legacy, 0.9) > 0.9);
+}
+
+#[test]
+fn exposure_individual_channels_and_bypass() {
+ let g = [0.5, 0.5, 0.5, 1.0];
+ let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g);
+ assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5);
+ // Master values are ignored in Individual Channels mode.
+ assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5));
+ // Bypass Linear Light Conversion: +1 stop doubles the raw value.
+ let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]);
+ assert!((raw[0] - 0.5).abs() < 1e-5);
+ // Negative results are mirrored, not clipped.
+ let neg =
+ px("ec.color.exposure", &[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))], [0.25, 0.25, 0.25, 1.0]);
+ assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
+}
+
+/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
+/// of 0.05, valid before, as in Photoshop) survives loading and still renders.
+#[test]
+fn exposure_has_after_effects_ranges_and_keeps_old_values() {
+ use effectcraft_project::build::Ids;
+ let spec = crate::find("ec.color.exposure").unwrap();
+ let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
+ let sliders: Vec<_> = spec
+ .params
+ .iter()
+ .filter_map(|p| match p.ui {
+ ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(sliders.len(), 12);
+ for (i, (id, r)) in sliders.iter().enumerate() {
+ assert_eq!(*r, want[i % 3], "{id}");
+ }
+ let mut next = 1;
+ let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
+ let gamma = g.prop_mut("master/gamma").unwrap();
+ gamma.value = num(0.05);
+ gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
+ crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
+ let gamma = g.prop_mut("master/gamma").unwrap();
+ assert_eq!(gamma.value, num(0.05));
+ assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
+ let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
+ assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
+}
+
+/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
+/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
+/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
+#[test]
+fn exposure_matches_after_effects_gamma_2_2() {
+ let red = [1.0, 0.0, 0.0, 1.0];
+ let code = |o: [f32; 4], i: usize| o[i] * 255.0;
+ // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
+ for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
+ let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
+ for i in [1, 2] {
+ assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
+ }
+ assert!(o[0] >= 1.0, "{o:?}");
+ }
+ // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
+ let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
+ assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
+ let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
+ assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
+ // Default settings leave 32-bpc values alone, negative and over-range ones included.
+ let hdr = [-0.25, 0.5, 2.0, 1.0];
+ assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
+ // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
+ let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
+ assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
+}
+
+#[test]
+fn change_to_color_change_modes_tolerances_and_matte() {
+ let red = [0.8, 0.1, 0.1, 1.0];
+ let green = [0.1, 0.8, 0.1, 1.0];
+ let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))];
+ // Hue only: red turns blue with its own lightness / saturation; green is untouched.
+ let o = px("ec.color.changetocolor", &base, red);
+ assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}");
+ assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5));
+ // The matte is white where matched, black elsewhere.
+ let mut m = base.to_vec();
+ m.push(("viewCorrectionMatte", Value::Bool(true)));
+ assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5));
+ assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5));
+ // A lightness tolerance of 0 rejects a lighter red.
+ let mut strict = m.clone();
+ strict.push(("toleranceGroup/lightness", num(0.0)));
+ strict.push(("softness", num(0.0)));
+ assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5));
+ // Hue, Lightness & Saturation, set: a matched pixel becomes the To colour.
+ let mut all = base.to_vec();
+ all.push(("change", Value::Enum(3)));
+ assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4));
+}
+
+#[test]
+fn leave_color_tolerance_softness_and_match_mode() {
+ let green = [0.1, 0.8, 0.1, 1.0];
+ let lc = |vals: &[(&str, Value)]| {
+ let mut v = vec![("amount", num(100.0))];
+ v.extend_from_slice(vals);
+ px("ec.color.leavecolor", &v, green)
+ };
+ let g = luminance(0.1, 0.8, 0.1);
+ assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5));
+ assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6));
+ // Softness keeps part of the colour.
+ let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]);
+ assert!(s[1] > g + 0.05 && s[1] < 0.8);
+ // Using Hue: a dark red still matches pure red.
+ let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]);
+ assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6));
+}
+
+#[test]
+fn photo_filter_presets_and_custom() {
+ let g = [0.5, 0.5, 0.5, 1.0];
+ let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g);
+ assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}");
+ let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g);
+ assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]);
+ // The Color swatch is ignored by the presets.
+ let warm = |c| px("ec.color.photofilter", &[("color", c)], g);
+ assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0)));
+}
+
+#[test]
+fn color_balance_preserve_luminosity_and_black_stretch() {
+ let g = [0.5, 0.5, 0.5, 1.0];
+ let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g);
+ assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]);
+ let dark = [0.1, 0.1, 0.1, 1.0];
+ assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
+ assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
+}
+
+#[test]
+fn levels_channel_controls_act_on_their_channel() {
+ let g = [0.5, 0.5, 0.5, 1.0];
+ assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
+ let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
+ assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
+ let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
+ assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
+ let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
+ assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
+ // Per-channel clipping follows its own popups.
+ let hot = [0.9, 0.9, 0.9, 1.0];
+ let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
+ let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
+ assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
+}
+
+#[test]
+fn hue_saturation_ranges_adjust_their_hues_only() {
+ let red = [0.8, 0.1, 0.1, 1.0];
+ let blue = [0.1, 0.1, 0.8, 1.0];
+ let grey = [0.5, 0.5, 0.5, 1.0];
+ let v = [("redsSaturation", num(-100.0))];
+ let r = px("ec.color.huesaturation", &v, red);
+ assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
+ assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
+ assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
+ // Blues hue +120°: blue becomes red.
+ let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
+ assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
+ // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
+ let orange_ish = {
+ let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
+ [r, g, b, 1.0]
+ };
+ let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
+ let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
+ assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
+ // Moving the range changes which hues it takes.
+ let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
+ assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
+ let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
+ let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
+ assert!(!huesat_ranges_identity(&ctx));
+}
+
+#[test]
+fn fill_mask_all_masks_feather_and_invert() {
+ let sq =
+ |x0: f64, x1: f64| crate::MaskShape { name: String::new(), points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], closed: true, inverted: false };
+ let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
+ let env = EffectEnv { masks: &masks, ..Default::default() };
+ let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
+ let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
+ let one = run(&[("fillMask", num(1.0))]);
+ assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
+ assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
+ assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
+ let all = run(&[("allMasks", Value::Bool(true))]);
+ assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
+ let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
+ assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
+ assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
+ let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
+ let v = soft.get(6, 5)[1];
+ assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
+ // Without masks, Fill fills the layer as before.
+ let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
+ assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
+}
+
+/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
+/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
+/// fill colour at half the layer's alpha, nothing of the ramp left.
+#[test]
+fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
+ let mut img = Image::new(16, 4);
+ for x in 0..16 {
+ let v = x as f32 / 15.0;
+ for y in 0..4 {
+ img.set(x, y, [v, 0.0, 0.0, 1.0]);
+ }
+ }
+ let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
+ let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
+ for x in [0, 7, 15] {
+ let p = out.get(x, 2);
+ let ae = [0.0, 0.251, 0.502, 0.502];
+ assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
+ }
+ // A half-transparent pixel ends at a quarter alpha, still the fill colour.
+ let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
+ assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
+}
+
+#[test]
+fn colorama_palettes_modify_selection_masking_and_add_phase() {
+ let grey = [0.5, 0.5, 0.5, 1.0];
+ let red = [1.0, 0.0, 0.0, 1.0];
+ // Ramp Grayscale: intensity 0.5 maps to mid grey.
+ let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
+ assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
+ // Custom palette: two stops, black at 0 and blue at 0.5.
+ let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
+ let c = px("ec.color.colorama", &custom, grey);
+ assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
+ // Modify Lightness keeps the hue of the pixel (red stays red).
+ let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
+ assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
+ // Pixel Selection: only colours near Matching Color change.
+ let sel = [
+ ("pixelSelection/matchingMode", Value::Enum(1)),
+ ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
+ ("pixelSelection/matchingTolerance", num(10.0)),
+ ];
+ assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
+ assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
+ // Composite Over Layer off: unselected pixels vanish.
+ let mut off = sel.to_vec();
+ off.push(("masking/compositeOverLayer", Value::Bool(false)));
+ assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
+ // Modify Alpha takes the palette alpha.
+ let alpha = [
+ ("outputCycle/usePresetPalette", Value::Enum(8)),
+ ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
+ ("modify/modifyAlpha", Value::Bool(true)),
+ ];
+ assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
+ // Palette parsing and evaluation.
+ let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
+ assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
+ assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
+ assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
+}
diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs
index cb389d901..a02ab5743 100644
--- a/crates/effects/src/distort3.rs
+++ b/crates/effects/src/distort3.rs
@@ -1084,337 +1084,4 @@ pub fn specs() -> Vec {
}
#[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 blobbylize_defaults_match_ae() {
- let s = crate::find("ec.distort.ccblobbylize").unwrap();
- let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
- assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
- assert_eq!(default("softness"), num(20.0));
- assert_eq!(default("cutAway"), num(25.0));
- assert_eq!(default("lightHeight"), num(65.0));
- assert_eq!(default("lightPosition"), pt(0.5, 0.5));
- assert_eq!(default("ambient"), num(75.0));
- assert_eq!(default("diffuse"), num(25.0));
- assert_eq!(default("specular"), num(100.0));
- assert_eq!(default("roughness"), num(0.025));
- assert_eq!(default("metal"), num(100.0));
- }
-
- #[test]
- fn blobbylize_cut_away_thresholds_a_height_ramp() {
- // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
- let im = crate::util::gen_image(1920, 8, |x, _| {
- let h = x as f32 / 1920.0;
- [h, h, h, 1.0]
- });
- let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
- assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
- assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
- let edge = o.img.get(480, 4)[3];
- assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
- let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
- assert!(partial <= 2, "{partial} antialiased columns instead of one");
- }
-
- #[test]
- fn blobbylize_cut_away_100_is_empty() {
- // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
- let im = crate::util::gen_image(1920, 8, |x, _| {
- let h = x as f32 / 1920.0;
- [h, h, h, 1.0]
- });
- let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
- assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
- }
-
- #[test]
- fn blobbylize_cut_away_100_clears_an_opaque_edge() {
- // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
- // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
- let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
- let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
- assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
- }
-
- #[test]
- fn blobbylize_softness_warps_a_step_matte_inward() {
- // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
- // edge at about x = 946, not at the source edge. The PR's matte-preserving version
- // left it at 960; the effect must warp the source matte.
- let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
- let o = run(
- "ec.distort.ccblobbylize",
- &[
- ("property", Value::Enum(3)),
- ("softness", num(20.0)),
- ("cutAway", num(0.0)),
- ("ambient", num(100.0)),
- ("diffuse", num(0.0)),
- ("specular", num(0.0)),
- ("metal", num(0.0)),
- ],
- im,
- &[],
- );
- assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
- assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
- assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
- }
-
- /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
- /// (200 × 100) layer bounds, after `id` with `set`.
- fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
- let s = crate::find(id).unwrap();
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- for (k, v) in set {
- assert!(params.values.contains_key(*k), "{id}: {k}");
- params.values.insert(k.to_string(), v.clone());
- }
- let mut im = Image::new(20, 10);
- im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
- let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
- crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
- }
-
- /// Alpha at layer point `p` (0 outside the buffer).
- fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
- let (x, y) = b.to_px(p);
- b.img.get(x.floor() as i64, y.floor() as i64)[3]
- }
-
- /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
- /// (comp-sized, as in After Effects), not cut off where the glyphs end.
- #[test]
- fn effects_on_text_reach_past_its_box() {
- let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
- assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
- assert!(smear.img.width < 200, "it grows only as far as pixels move");
- let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
- assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
- assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
- assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
- // A footage layer's buffer is its bounds already: no change.
- let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
- assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
- }
-
- #[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 power_pin_unstretch_expands_pinned_region() {
- let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
- let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
- let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
- let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
- assert_eq!(opaque(&off), 96);
- let mut set = inset.to_vec();
- set.push(("unstretch", Value::Bool(true)));
- let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
- assert_eq!(opaque(&on), 24 * 16);
- assert!(on.img.get(0, 0)[3] > 0.99);
- // Default corners are the identity either way.
- let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
- assert!(maxd(&id.img, &im) < 1e-4);
- }
-
- #[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 reshape_correspondence_points_steer_the_match() {
- // Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
- let im = img(40, 40);
- let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
- let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
- let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
- assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
- let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
- assert!(maxd(&twisted.img, &im) > 0.1);
- // Fraction mapping.
- assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
- assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
- assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
- }
-
- #[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);
- }
-}
+mod tests;
diff --git a/crates/effects/src/distort3/tests.rs b/crates/effects/src/distort3/tests.rs
new file mode 100644
index 000000000..b50d6b450
--- /dev/null
+++ b/crates/effects/src/distort3/tests.rs
@@ -0,0 +1,332 @@
+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 blobbylize_defaults_match_ae() {
+ let s = crate::find("ec.distort.ccblobbylize").unwrap();
+ let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
+ assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
+ assert_eq!(default("softness"), num(20.0));
+ assert_eq!(default("cutAway"), num(25.0));
+ assert_eq!(default("lightHeight"), num(65.0));
+ assert_eq!(default("lightPosition"), pt(0.5, 0.5));
+ assert_eq!(default("ambient"), num(75.0));
+ assert_eq!(default("diffuse"), num(25.0));
+ assert_eq!(default("specular"), num(100.0));
+ assert_eq!(default("roughness"), num(0.025));
+ assert_eq!(default("metal"), num(100.0));
+}
+
+#[test]
+fn blobbylize_cut_away_thresholds_a_height_ramp() {
+ // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
+ let im = crate::util::gen_image(1920, 8, |x, _| {
+ let h = x as f32 / 1920.0;
+ [h, h, h, 1.0]
+ });
+ let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
+ assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
+ assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
+ let edge = o.img.get(480, 4)[3];
+ assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
+ let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
+ assert!(partial <= 2, "{partial} antialiased columns instead of one");
+}
+
+#[test]
+fn blobbylize_cut_away_100_is_empty() {
+ // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
+ let im = crate::util::gen_image(1920, 8, |x, _| {
+ let h = x as f32 / 1920.0;
+ [h, h, h, 1.0]
+ });
+ let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
+ assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
+}
+
+#[test]
+fn blobbylize_cut_away_100_clears_an_opaque_edge() {
+ // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
+ // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
+ let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
+ let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
+ assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
+}
+
+#[test]
+fn blobbylize_softness_warps_a_step_matte_inward() {
+ // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
+ // edge at about x = 946, not at the source edge. The PR's matte-preserving version
+ // left it at 960; the effect must warp the source matte.
+ let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
+ let o = run(
+ "ec.distort.ccblobbylize",
+ &[
+ ("property", Value::Enum(3)),
+ ("softness", num(20.0)),
+ ("cutAway", num(0.0)),
+ ("ambient", num(100.0)),
+ ("diffuse", num(0.0)),
+ ("specular", num(0.0)),
+ ("metal", num(0.0)),
+ ],
+ im,
+ &[],
+ );
+ assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
+ assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
+ assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
+}
+
+/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
+/// (200 × 100) layer bounds, after `id` with `set`.
+fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
+ let s = crate::find(id).unwrap();
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ for (k, v) in set {
+ assert!(params.values.contains_key(*k), "{id}: {k}");
+ params.values.insert(k.to_string(), v.clone());
+ }
+ let mut im = Image::new(20, 10);
+ im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
+ let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
+ crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
+}
+
+/// Alpha at layer point `p` (0 outside the buffer).
+fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
+ let (x, y) = b.to_px(p);
+ b.img.get(x.floor() as i64, y.floor() as i64)[3]
+}
+
+/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
+/// (comp-sized, as in After Effects), not cut off where the glyphs end.
+#[test]
+fn effects_on_text_reach_past_its_box() {
+ let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
+ assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
+ assert!(smear.img.width < 200, "it grows only as far as pixels move");
+ let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
+ assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
+ assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
+ assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
+ // A footage layer's buffer is its bounds already: no change.
+ let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
+ assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
+}
+
+#[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 power_pin_unstretch_expands_pinned_region() {
+ let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
+ let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
+ let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
+ let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
+ assert_eq!(opaque(&off), 96);
+ let mut set = inset.to_vec();
+ set.push(("unstretch", Value::Bool(true)));
+ let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
+ assert_eq!(opaque(&on), 24 * 16);
+ assert!(on.img.get(0, 0)[3] > 0.99);
+ // Default corners are the identity either way.
+ let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
+ assert!(maxd(&id.img, &im) < 1e-4);
+}
+
+#[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 reshape_correspondence_points_steer_the_match() {
+ // Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
+ let im = img(40, 40);
+ let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
+ let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
+ let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
+ assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
+ let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
+ assert!(maxd(&twisted.img, &im) > 0.1);
+ // Fraction mapping.
+ assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
+ assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
+ assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
+}
+
+#[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 80d32ce0e..62bce1706 100644
--- a/crates/effects/src/generate2.rs
+++ b/crates/effects/src/generate2.rs
@@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [
"Tubular",
];
-/// Shaping used for Cell Pattern option `i` (see [`cell_value`]).
+/// Shaping used for Cell Pattern option `i` (see `cell_value`).
pub fn pattern_kind(i: u32) -> u32 {
match i {
0..=5 => i,
@@ -1223,315 +1223,4 @@ pub fn specs() -> Vec {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::Params;
- use effectcraft_raster::Image;
-
- fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf {
- let s = crate::find(id).unwrap();
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- let ls = [img.width as f64, img.height as f64];
- 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 {
- params.values.insert(k.to_string(), v.clone());
- }
- 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 })
- }
-
- #[test]
- fn cell_pattern_is_deterministic_and_seeded() {
- let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
- let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
- assert_eq!(a.img, b2.img);
- let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30));
- assert_ne!(a.img, c.img);
- assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0])));
- }
-
- #[test]
- fn ellipse_ring_coverage() {
- let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64));
- // Ring passes through (32 + 20, 32); centre is empty.
- assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32));
- assert!(o.img.get(32, 32)[3] < 1e-3);
- assert!(o.img.get(2, 2)[3] < 1e-3);
- }
-
- #[test]
- fn lightning_is_deterministic_per_conductivity() {
- let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
- let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
- assert_eq!(a.img, b2.img);
- assert!(a.img.data.iter().any(|p| p[3] > 0.5));
- let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48));
- assert_ne!(a.img, c.img);
- }
-
- #[test]
- fn beam_draws_between_points() {
- let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32));
- assert!(o.img.get(32, 16)[3] > 0.99);
- assert!(o.img.get(32, 2)[3] < 1e-3);
- assert!(o.img.get(2, 16)[3] < 1e-3);
- }
-
- #[test]
- fn cell_pattern_tiles() {
- // 4×4-cell tiles of 8 px: the pattern repeats every 32 px.
- let set = [
- ("size", num(8.0)),
- ("tilingOptions/enableTiling", Value::Bool(true)),
- ("tilingOptions/cellsHorizontal", num(4.0)),
- ("tilingOptions/cellsVertical", num(4.0)),
- ];
- let o = run("ec.generate.cellpattern", &set, Image::new(64, 64));
- for (x, y) in [(3, 5), (10, 20), (17, 2)] {
- let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32));
- assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}");
- }
- }
-
- #[test]
- fn radio_waves_expansion_velocity_and_star() {
- let r = |set: &[(&str, Value)], t: f64| {
- let s = crate::find("ec.generate.radiowaves").unwrap();
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
- for (k, v) in set {
- params.values.insert(k.to_string(), v.clone());
- }
- let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() };
- crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 })
- };
- // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px.
- let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))];
- let o = r(&base, 0.2);
- assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32));
- assert!(o.img.get(32, 32)[3] < 0.01);
- // Velocity moves the wave toward Direction (90° = right).
- let mut moving = base.to_vec();
- moving.push(("waveMotion/velocity", num(50.0)));
- let o = r(&moving, 0.2);
- assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32));
- // A 4-point star: the outline dips toward the centre between the points.
- let mut star = base.to_vec();
- star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]);
- let o = r(&star, 0.2);
- assert!(o.img.get(32, 12)[3] > 0.9, "point straight up");
- // Between points (45°) the outline sits near half the radius.
- assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24));
- assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
- }
-
- #[test]
- fn lightning_alpha_obstacle_and_expert_settings() {
- let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
- let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::();
- // A wall of opaque pixels across the middle of the bolt's path.
- let mut wall = Image::new(64, 48);
- for y in 0..48 {
- for x in 0..64 {
- if (18..30).contains(&y) && x < 50 {
- wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
- }
- }
- }
- let opts = |o: f64| {
- vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]
- };
- let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
- let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
- assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
- // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
- let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
- for (id, v) in [
- ("expertSettings/fractalType", Value::Enum(2)),
- ("expertSettings/coreDrain", num(100.0)),
- ("expertSettings/forkStrength", num(100.0)),
- ("expertSettings/forkVariation", num(100.0)),
- ] {
- assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
- }
- let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
- assert!(sum(&drained.img) < sum(&base.img));
- let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
- let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
- assert_eq!(no_forks.img, term.img, "no forks either way");
- assert!(sum(&no_forks.img) < sum(&base.img));
- }
-
- #[test]
- fn lightning_types_and_decay_main_core() {
- let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
- let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48));
- assert_eq!(a.img, strike.img, "Strike is the default");
- for t in 0..8 {
- let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48));
- assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws");
- }
- let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
- let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48));
- let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48));
- assert!(sum(&d.img) < sum(&n.img));
- }
-
- #[test]
- fn beam_3d_perspective_controls_thickness() {
- // Short beam near the start: with 3D Perspective its thickness comes from its position
- // (thin, near the start); without, its head takes the full ending thickness.
- let set = |p: bool| {
- vec![
- ("length", num(20.0)),
- ("time", num(0.0)),
- ("softness", num(0.0)),
- ("startThickness", num(2.0)),
- ("endThickness", num(20.0)),
- ("perspective3d", Value::Bool(p)),
- ]
- };
- let on = run("ec.generate.beam", &set(true), Image::new(64, 32));
- let off = run("ec.generate.beam", &set(false), Image::new(64, 32));
- let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
- assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img));
- }
-
- #[test]
- fn lens_flare_brightest_at_center() {
- let o = run("ec.generate.lensflare", &[], Image::new(64, 64));
- let c = o.img.get(16, 16)[0];
- assert!(c > o.img.get(60, 4)[0]);
- assert!(c > 0.5);
- }
-
- fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
- let mut v: Vec<(&str, Value)> =
- vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
- v.extend_from_slice(set);
- crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
- }
-
- #[test]
- fn signed_distance_of_a_square() {
- let (w, h) = (20, 20);
- let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
- let d = crate::util::signed_distance(&inside, w, h);
- assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
- assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
- assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
- }
-
- #[test]
- fn radio_waves_mask_contours_profile_reflection_and_birth() {
- // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
- let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
- let masks = [sq];
- let env = crate::EffectEnv { masks: &masks, ..Default::default() };
- let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
- assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
- assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
- // Rounded corner: diagonal distance 10 from (37, 37).
- assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
- // No mask chosen: nothing drawn.
- assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
- // Image Contours: the bright square of a source layer.
- struct Src;
- impl crate::EffectHost for Src {
- fn layer(&self, _: u64, _: bool) -> Option {
- let mut img = Image::new(64, 64);
- for y in 27..37 {
- for x in 27..37 {
- img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
- }
- }
- Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
- }
- fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
- None
- }
- fn params_at(&self, t: f64) -> Option {
- // Colour animates from red (born at 0) to green (born later).
- let s = crate::find("ec.generate.radiowaves").unwrap();
- let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
- p.values.insert("waveMotion/frequency".into(), num(1.0));
- p.values.insert("waveMotion/expansion".into(), num(20.0));
- p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
- Some(p)
- }
- }
- let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
- let ic = rw(
- &[
- ("waveType", Value::Enum(1)),
- ("imageContour/sourceLayer", Value::Layer(Some(1))),
- ("imageContour/preBlur", num(0.0)),
- ("imageContour/tolerance", num(0.0)),
- ],
- 0.1,
- env,
- );
- assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
- // Source Center off the square shifts the contour onto the producer point accordingly.
- let sh = rw(
- &[
- ("waveType", Value::Enum(1)),
- ("imageContour/sourceLayer", Value::Layer(Some(1))),
- ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
- ("imageContour/preBlur", num(0.0)),
- ("imageContour/tolerance", num(0.0)),
- ],
- 0.1,
- env,
- );
- assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
- // Profile: Taper Both thins the stroke edges; Square is solid across.
- let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
- let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
- let mut tap = wide.to_vec();
- tap.push(("waveStroke/profile", Value::Enum(3)));
- let tap = rw(&tap, 0.2, crate::EffectEnv::default());
- assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
- assert!(tap.get(52, 32)[3] > 0.8);
- // Reflection: a fast wave bounces back off the right edge.
- let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
- let gone = rw(&fast, 0.3, crate::EffectEnv::default());
- let mut rf = fast.to_vec();
- rf.push(("waveMotion/reflection", Value::Bool(true)));
- let back = rw(&rf, 0.3, crate::EffectEnv::default());
- let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
- assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
- // Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
- let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
- let (old, new) = (both.get(55, 32), both.get(35, 32));
- assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
- assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
- }
-
- #[test]
- fn cell_pattern_hq_variants_antialias() {
- let s = crate::find("ec.generate.cellpattern").unwrap();
- let r = |pat: u32| {
- let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
- params.values.insert("cellPattern".into(), Value::Enum(pat));
- params.values.insert("size".into(), num(12.0));
- let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
- crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
- };
- let distinct = |img: &Image| {
- let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
- v.sort_unstable();
- v.dedup();
- v.len()
- };
- let (std, hq) = (r(4), r(9));
- assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
- assert_ne!(std, hq);
- assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
- }
-}
+mod tests;
diff --git a/crates/effects/src/generate2/tests.rs b/crates/effects/src/generate2/tests.rs
new file mode 100644
index 000000000..c2acff446
--- /dev/null
+++ b/crates/effects/src/generate2/tests.rs
@@ -0,0 +1,309 @@
+use super::*;
+use crate::Params;
+use effectcraft_raster::Image;
+
+fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf {
+ let s = crate::find(id).unwrap();
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ let ls = [img.width as f64, img.height as f64];
+ 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 {
+ params.values.insert(k.to_string(), v.clone());
+ }
+ 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 })
+}
+
+#[test]
+fn cell_pattern_is_deterministic_and_seeded() {
+ let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
+ let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
+ assert_eq!(a.img, b2.img);
+ let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30));
+ assert_ne!(a.img, c.img);
+ assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0])));
+}
+
+#[test]
+fn ellipse_ring_coverage() {
+ let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64));
+ // Ring passes through (32 + 20, 32); centre is empty.
+ assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32));
+ assert!(o.img.get(32, 32)[3] < 1e-3);
+ assert!(o.img.get(2, 2)[3] < 1e-3);
+}
+
+#[test]
+fn lightning_is_deterministic_per_conductivity() {
+ let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
+ let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
+ assert_eq!(a.img, b2.img);
+ assert!(a.img.data.iter().any(|p| p[3] > 0.5));
+ let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48));
+ assert_ne!(a.img, c.img);
+}
+
+#[test]
+fn beam_draws_between_points() {
+ let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32));
+ assert!(o.img.get(32, 16)[3] > 0.99);
+ assert!(o.img.get(32, 2)[3] < 1e-3);
+ assert!(o.img.get(2, 16)[3] < 1e-3);
+}
+
+#[test]
+fn cell_pattern_tiles() {
+ // 4×4-cell tiles of 8 px: the pattern repeats every 32 px.
+ let set = [
+ ("size", num(8.0)),
+ ("tilingOptions/enableTiling", Value::Bool(true)),
+ ("tilingOptions/cellsHorizontal", num(4.0)),
+ ("tilingOptions/cellsVertical", num(4.0)),
+ ];
+ let o = run("ec.generate.cellpattern", &set, Image::new(64, 64));
+ for (x, y) in [(3, 5), (10, 20), (17, 2)] {
+ let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32));
+ assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}");
+ }
+}
+
+#[test]
+fn radio_waves_expansion_velocity_and_star() {
+ let r = |set: &[(&str, Value)], t: f64| {
+ let s = crate::find("ec.generate.radiowaves").unwrap();
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
+ for (k, v) in set {
+ params.values.insert(k.to_string(), v.clone());
+ }
+ let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() };
+ crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 })
+ };
+ // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px.
+ let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))];
+ let o = r(&base, 0.2);
+ assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32));
+ assert!(o.img.get(32, 32)[3] < 0.01);
+ // Velocity moves the wave toward Direction (90° = right).
+ let mut moving = base.to_vec();
+ moving.push(("waveMotion/velocity", num(50.0)));
+ let o = r(&moving, 0.2);
+ assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32));
+ // A 4-point star: the outline dips toward the centre between the points.
+ let mut star = base.to_vec();
+ star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]);
+ let o = r(&star, 0.2);
+ assert!(o.img.get(32, 12)[3] > 0.9, "point straight up");
+ // Between points (45°) the outline sits near half the radius.
+ assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24));
+ assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
+}
+
+#[test]
+fn lightning_alpha_obstacle_and_expert_settings() {
+ let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
+ let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::();
+ // A wall of opaque pixels across the middle of the bolt's path.
+ let mut wall = Image::new(64, 48);
+ for y in 0..48 {
+ for x in 0..64 {
+ if (18..30).contains(&y) && x < 50 {
+ wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
+ }
+ }
+ }
+ let opts =
+ |o: f64| vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))];
+ let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
+ let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
+ assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
+ // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
+ let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
+ for (id, v) in [
+ ("expertSettings/fractalType", Value::Enum(2)),
+ ("expertSettings/coreDrain", num(100.0)),
+ ("expertSettings/forkStrength", num(100.0)),
+ ("expertSettings/forkVariation", num(100.0)),
+ ] {
+ assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
+ }
+ let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
+ assert!(sum(&drained.img) < sum(&base.img));
+ let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
+ let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
+ assert_eq!(no_forks.img, term.img, "no forks either way");
+ assert!(sum(&no_forks.img) < sum(&base.img));
+}
+
+#[test]
+fn lightning_types_and_decay_main_core() {
+ let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
+ let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48));
+ assert_eq!(a.img, strike.img, "Strike is the default");
+ for t in 0..8 {
+ let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48));
+ assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws");
+ }
+ let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
+ let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48));
+ let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48));
+ assert!(sum(&d.img) < sum(&n.img));
+}
+
+#[test]
+fn beam_3d_perspective_controls_thickness() {
+ // Short beam near the start: with 3D Perspective its thickness comes from its position
+ // (thin, near the start); without, its head takes the full ending thickness.
+ let set = |p: bool| {
+ vec![
+ ("length", num(20.0)),
+ ("time", num(0.0)),
+ ("softness", num(0.0)),
+ ("startThickness", num(2.0)),
+ ("endThickness", num(20.0)),
+ ("perspective3d", Value::Bool(p)),
+ ]
+ };
+ let on = run("ec.generate.beam", &set(true), Image::new(64, 32));
+ let off = run("ec.generate.beam", &set(false), Image::new(64, 32));
+ let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
+ assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img));
+}
+
+#[test]
+fn lens_flare_brightest_at_center() {
+ let o = run("ec.generate.lensflare", &[], Image::new(64, 64));
+ let c = o.img.get(16, 16)[0];
+ assert!(c > o.img.get(60, 4)[0]);
+ assert!(c > 0.5);
+}
+
+fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
+ let mut v: Vec<(&str, Value)> =
+ vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
+ v.extend_from_slice(set);
+ crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
+}
+
+#[test]
+fn signed_distance_of_a_square() {
+ let (w, h) = (20, 20);
+ let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
+ let d = crate::util::signed_distance(&inside, w, h);
+ assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
+ assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
+ assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
+}
+
+#[test]
+fn radio_waves_mask_contours_profile_reflection_and_birth() {
+ // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
+ let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
+ let masks = [sq];
+ let env = crate::EffectEnv { masks: &masks, ..Default::default() };
+ let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
+ assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
+ assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
+ // Rounded corner: diagonal distance 10 from (37, 37).
+ assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
+ // No mask chosen: nothing drawn.
+ assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
+ // Image Contours: the bright square of a source layer.
+ struct Src;
+ impl crate::EffectHost for Src {
+ fn layer(&self, _: u64, _: bool) -> Option {
+ let mut img = Image::new(64, 64);
+ for y in 27..37 {
+ for x in 27..37 {
+ img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
+ }
+ }
+ Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
+ }
+ fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
+ None
+ }
+ fn params_at(&self, t: f64) -> Option {
+ // Colour animates from red (born at 0) to green (born later).
+ let s = crate::find("ec.generate.radiowaves").unwrap();
+ let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
+ p.values.insert("waveMotion/frequency".into(), num(1.0));
+ p.values.insert("waveMotion/expansion".into(), num(20.0));
+ p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
+ Some(p)
+ }
+ }
+ let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
+ let ic = rw(
+ &[
+ ("waveType", Value::Enum(1)),
+ ("imageContour/sourceLayer", Value::Layer(Some(1))),
+ ("imageContour/preBlur", num(0.0)),
+ ("imageContour/tolerance", num(0.0)),
+ ],
+ 0.1,
+ env,
+ );
+ assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
+ // Source Center off the square shifts the contour onto the producer point accordingly.
+ let sh = rw(
+ &[
+ ("waveType", Value::Enum(1)),
+ ("imageContour/sourceLayer", Value::Layer(Some(1))),
+ ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
+ ("imageContour/preBlur", num(0.0)),
+ ("imageContour/tolerance", num(0.0)),
+ ],
+ 0.1,
+ env,
+ );
+ assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
+ // Profile: Taper Both thins the stroke edges; Square is solid across.
+ let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
+ let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
+ let mut tap = wide.to_vec();
+ tap.push(("waveStroke/profile", Value::Enum(3)));
+ let tap = rw(&tap, 0.2, crate::EffectEnv::default());
+ assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
+ assert!(tap.get(52, 32)[3] > 0.8);
+ // Reflection: a fast wave bounces back off the right edge.
+ let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
+ let gone = rw(&fast, 0.3, crate::EffectEnv::default());
+ let mut rf = fast.to_vec();
+ rf.push(("waveMotion/reflection", Value::Bool(true)));
+ let back = rw(&rf, 0.3, crate::EffectEnv::default());
+ let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::();
+ assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
+ // Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
+ let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
+ let (old, new) = (both.get(55, 32), both.get(35, 32));
+ assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
+ assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
+}
+
+#[test]
+fn cell_pattern_hq_variants_antialias() {
+ let s = crate::find("ec.generate.cellpattern").unwrap();
+ let r = |pat: u32| {
+ let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
+ params.values.insert("cellPattern".into(), Value::Enum(pat));
+ params.values.insert("size".into(), num(12.0));
+ let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
+ crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
+ };
+ let distinct = |img: &Image| {
+ let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
+ v.sort_unstable();
+ v.dedup();
+ v.len()
+ };
+ let (std, hq) = (r(4), r(9));
+ assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
+ assert_ne!(std, hq);
+ assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
+}
diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs
index 6beec4cb7..a61c3f769 100644
--- a/crates/effects/src/generate3.rs
+++ b/crates/effects/src/generate3.rs
@@ -17,2430 +17,26 @@ 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, Params, 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 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 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),
- };
- });
-}
-
-/// Coverage of a plan-drawn generator: round-capped segments rasterised by [`raster_segs`]
-/// with `hardness`, or the union of convex polygons ([`raster_convex`]).
-#[derive(Clone, Debug)]
-pub enum Coverage {
- Segs { segs: Vec, hardness: f64 },
- Convex(Vec>),
-}
-
-impl Coverage {
- fn raster(&self, w: usize, h: usize) -> Plane {
- match self {
- Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness),
- Coverage::Convex(polys) => raster_convex(w, h, polys),
- }
- }
-}
-
-/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu
-/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as [`paint`] (On Original
-/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under).
-#[derive(Clone, Debug)]
-pub struct PaintPlan {
- pub cov: Coverage,
- pub color: [f32; 4],
- pub opacity: f32,
- pub style: u32,
-}
-
-/// Draw a [`PaintPlan`].
-fn draw_paint(b: &mut Buf, plan: &PaintPlan) {
- let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize);
- if plan.style == 3 {
- let color = plan.color;
- 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);
- });
- } else {
- paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style);
- }
-}
-
-/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it.
-pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool {
- match id {
- // Image Contours read the layer itself when no (resolvable) Input Layer is chosen.
- "ec.generate.vegas" => ctx.params.e("stroke") == 0,
- "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true,
- "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0,
- _ => false,
- }
-}
-
-/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read
-/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is
-/// cleared when it composites On Transparent, else kept).
-pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option {
- match id {
- "ec.generate.stroke" => Some(stroke_plan(ctx, b)),
- "ec.generate.scribble" => scribble_plan(ctx, b),
- "ec.generate.vegas" => Some(vegas_plan(ctx, b)),
- "ec.generate.writeon" => Some(write_on_plan(ctx, b)),
- _ => None,
- }
-}
-
-/// Scribble's Composite as a [`paint`] style.
-pub fn scribble_style(ctx: &EffectCtx) -> u32 {
- match ctx.params.e("composite") {
- 0 => 0,
- 2 => 2,
- _ => 1,
- }
-}
-
-#[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 {
- /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices).
- over_julia: bool,
- 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
-}
-
-/// Fractal Set Choice options.
-pub(crate) const FRACTAL_SETS: [&str; 6] =
- ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"];
-/// Fractal Palette options.
-pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"];
-/// Fractal Oversample Method options.
-pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"];
-
-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("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY"));
- let mmag = pr.f("mandelbrot/mandelbrotMagnification");
- let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01);
- let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY"));
- let jmag = pr.f("julia/juliaMagnification");
- let jlim = pr.f("julia/juliaEscapeLimit").max(1.01);
- let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY"));
- let overlay = pr.b("fractalColor/overlay");
- let transparency = pr.b("fractalColor/transparency");
- let transparent_inside = transparency || overlay;
- let palette = pr.e("fractalColor/palette");
- let hue = (pr.f("fractalColor/hue") / 360.0) as f32;
- let steps = pr.f("fractalColor/cycleSteps").max(1.0);
- let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0;
- let edge = pr.b("fractalColor/edgeHighlight");
- let every = pr.e("highQualitySettings/samplingMethod") == 1;
- let factor = pr.f("highQualitySettings/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]);
- // FRACTAL_SETS order.
- let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse };
- let sets: (FracSet, Option) = match set_choice {
- 1 => (set(false, true, false), None),
- 2 => (set(false, false, true), None),
- 3 => (set(false, true, true), None),
- 4 => (set(true, false, false), None),
- 5 => (set(true, true, false), None),
- _ => (set(false, false, 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 {
- let z0 = if s.over_julia { jc } else { (0.0, 0.0) };
- escape(z0, (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 {
- // Solid Color: only the inside of the set is drawn (Transparency flips the sides).
- if palette == 3 {
- let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5);
- return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] };
- }
- let Some(n) = n else {
- return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] };
- };
- let u = n / steps + cyc_off;
- let band = (u.rem_euclid(1.0)) as f32;
- let (r, g, bl) = match palette {
- // Hue Wheel: the full hue circle at full saturation and brightness.
- 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5),
- // Black And White: alternating bands.
- 2 => {
- let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 };
- (v, v, v)
- }
- // Lightness Gradient: black → hue → white, the hue moving 45° each cycle.
- _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 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