From 97cf7e2c02336bfb7135e3b651c34d1a615bb724 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 20:39:38 -0400 Subject: [PATCH 01/10] M9.11: Audio effects (Compressor, Distortion, Gate) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/audio_fx.rs | 241 +++++++++++++++++++++++++++- crates/render/src/tests_audio_fx.rs | 12 +- 2 files changed, 249 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index 855fd10e..bd80519a 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -1,5 +1,5 @@ -//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Delay, Flange & Chorus, -//! High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone. +//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Compressor, Delay, Distortion, +//! Flange & Chorus, Gate, High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone. //! //! They are ordinary effect instances on a layer (so their parameters animate and show in //! Effect Controls), pass video through unchanged, and are applied to the layer's audio by the @@ -12,7 +12,10 @@ //! DSP is textbook: second-order sections use the public RBJ "Audio EQ Cookbook" formulas; //! Reverb is a Schroeder/Moorer network (parallel low-pass feedback combs into series //! all-passes, as in the published Freeverb description); chorus/flange/vibrato are modulated -//! fractional delay lines. +//! fractional delay lines. Compressor and Gate are feed-forward dynamics processors on a +//! stereo-linked peak envelope (one-pole attack/release smoothing, the textbook quadratic soft-knee +//! gain computer); Distortion is a static waveshaper (soft clip, hard clip, fold-back, bit crush) +//! followed by a one-pole tone filter. use std::f64::consts::PI; @@ -60,6 +63,20 @@ pub fn specs() -> Vec { p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)), ], ), + spec( + "ec.audio.compressor", + "Compressor", + vec![ + p("threshold", "Threshold (dB)", num(-20.0), slider(-60.0, 0.0, -60.0, 0.0, 2)), + p("ratio", "Ratio", num(4.0), slider(1.0, 30.0, 1.0, 20.0, 2)), + p("attack", "Attack (ms)", num(10.0), slider(0.0, 500.0, 0.0, 200.0, 2)), + p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)), + p("knee", "Knee (dB)", num(0.0), slider(0.0, 24.0, 0.0, 24.0, 2)), + p("makeupGain", "Makeup Gain (dB)", num(0.0), slider(-24.0, 36.0, 0.0, 24.0, 2)), + pp("dryOut", "Dry Out", pct(0.0)), + pp("wetOut", "Wet Out", pct(100.0)), + ], + ), spec( "ec.audio.delay", "Delay", @@ -71,6 +88,19 @@ pub fn specs() -> Vec { pp("wetOut", "Wet Out", pct(75.0)), ], ), + spec( + "ec.audio.distortion", + "Distortion", + vec![ + p("distortionType", "Distortion Type", Value::Enum(0), popup(&["Soft Clip", "Hard Clip", "Fold Back", "Bit Crush"])), + p("drive", "Drive (dB)", num(12.0), slider(0.0, 48.0, 0.0, 48.0, 2)), + p("bitDepth", "Bit Depth", num(8.0), slider(1.0, 16.0, 1.0, 16.0, 0)), + p("tone", "Tone (Hz)", num(8000.0), slider(200.0, 20000.0, 200.0, 20000.0, 1)), + p("outputGain", "Output Gain (dB)", num(-6.0), slider(-48.0, 12.0, -24.0, 12.0, 2)), + pp("dryOut", "Dry Out", pct(0.0)), + pp("wetOut", "Wet Out", pct(100.0)), + ], + ), spec( "ec.audio.flangechorus", "Flange & Chorus", @@ -86,6 +116,17 @@ pub fn specs() -> Vec { pp("wetOut", "Wet Out", pct(50.0)), ], ), + spec( + "ec.audio.gate", + "Gate", + vec![ + p("threshold", "Threshold (dB)", num(-40.0), slider(-90.0, 0.0, -80.0, 0.0, 2)), + p("attack", "Attack (ms)", num(1.0), slider(0.0, 500.0, 0.0, 100.0, 2)), + p("hold", "Hold (ms)", num(50.0), slider(0.0, 5000.0, 0.0, 500.0, 2)), + p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)), + p("range", "Range (dB)", num(-80.0), slider(-120.0, 0.0, -90.0, 0.0, 2)), + ], + ), spec( "ec.audio.highlowpass", "High-Low Pass", @@ -167,6 +208,14 @@ pub fn preroll(id: &str, params: &Params) -> f64 { match id { "ec.audio.basstreble" | "ec.audio.highlowpass" | "ec.audio.parametriceq" => 0.05, "ec.audio.flangechorus" | "ec.audio.modulator" => 0.06, + "ec.audio.distortion" => 0.01, + // The envelope settles within a few attack/release time constants. + "ec.audio.compressor" | "ec.audio.gate" => { + let rel = params.f("release").max(1.0) / 1000.0; + let att = params.f("attack").max(0.0) / 1000.0; + let hold = if id == "ec.audio.gate" { params.f("hold").max(0.0) / 1000.0 } else { 0.0 }; + (9.0 * rel + 9.0 * att + hold + 0.01).min(10.0) + } "ec.audio.delay" => { let d = params.f("delayTime").max(1.0) / 1000.0; let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99); @@ -231,6 +280,9 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64 } } "ec.audio.delay" => delay(params, buf, sr), + "ec.audio.compressor" => compressor(params, buf, sr), + "ec.audio.distortion" => distortion(params, buf, sr), + "ec.audio.gate" => gate(params, buf, sr), "ec.audio.flangechorus" => flange_chorus(params, buf, sr, start), "ec.audio.modulator" => modulator(params, buf, sr, start), "ec.audio.reverb" => reverb(params, buf, sr), @@ -362,6 +414,126 @@ fn delay(params: &Params, buf: &mut [f32], sr: f64) { } } +#[inline] +fn db_to_lin(db: f64) -> f64 { + 10f64.powf(db / 20.0) +} + +#[inline] +fn lin_to_db(v: f64) -> f64 { + 20.0 * v.max(1e-12).log10() +} + +/// One-pole smoothing coefficient for a time constant of `ms` milliseconds. +fn smoothing(ms: f64, sr: f64) -> f64 { + if ms <= 0.0 { 0.0 } else { (-1.0 / (ms / 1000.0 * sr)).exp() } +} + +/// Static compressor curve: gain change (dB, ≤ 0) for an input level `x` dB (quadratic soft +/// knee of width `knee` around the threshold). +pub fn compressor_gain_db(x: f64, threshold: f64, ratio: f64, knee: f64) -> f64 { + let r = ratio.max(1.0); + let over = x - threshold; + let y = if knee > 0.0 && 2.0 * over.abs() <= knee { + x + (1.0 / r - 1.0) * (over + knee / 2.0).powi(2) / (2.0 * knee) + } else if over > 0.0 { + threshold + over / r + } else { + x + }; + y - x +} + +fn compressor(params: &Params, buf: &mut [f32], sr: f64) { + let th = params.f("threshold"); + let ratio = params.f("ratio").max(1.0); + let knee = params.f("knee").max(0.0); + let att = smoothing(params.f("attack"), sr); + let rel = smoothing(params.f("release").max(0.001), sr); + let makeup = params.f("makeupGain"); + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + // Smoothed gain change in dB (≤ 0): attack while the reduction grows, release as it shrinks. + let mut g = 0.0f64; + for f in buf.chunks_exact_mut(2) { + let peak = f[0].abs().max(f[1].abs()) as f64; + let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee); + let k = if target < g { att } else { rel }; + g = target + (g - target) * k; + let lin = db_to_lin(g + makeup) as f32; + for v in f.iter_mut() { + *v = *v * dry + *v * lin * wet; + } + } +} + +fn gate(params: &Params, buf: &mut [f32], sr: f64) { + let th = db_to_lin(params.f("threshold")); + let att = smoothing(params.f("attack"), sr); + let rel = smoothing(params.f("release").max(0.001), sr); + let hold = (params.f("hold").max(0.0) / 1000.0 * sr) as usize; + let floor = db_to_lin(params.f("range").min(0.0)); + // Openness 0..1 (closed..open), mapped to floor..1. + let mut g = 0.0f64; + let mut held = 0usize; + for f in buf.chunks_exact_mut(2) { + let peak = f[0].abs().max(f[1].abs()) as f64; + let open = if peak >= th { + held = hold; + true + } else if held > 0 { + held -= 1; + true + } else { + false + }; + let target = if open { 1.0 } else { 0.0 }; + let k = if target > g { att } else { rel }; + g = target + (g - target) * k; + let lin = (floor + (1.0 - floor) * g) as f32; + f[0] *= lin; + f[1] *= lin; + } +} + +/// The Distortion effect's waveshaper for one sample (`drive` linear gain, `bits` for Bit Crush). +pub fn distort_sample(kind: u32, x: f32, drive: f32, bits: f32) -> f32 { + let v = x * drive; + match kind { + 1 => v.clamp(-1.0, 1.0), + 2 => { + // Fold back: reflect at ±1 (a triangle function of the input). + let t = (v + 1.0).rem_euclid(4.0); + if t < 2.0 { t - 1.0 } else { 3.0 - t } + } + 3 => { + let q = 2f32.powf(bits.clamp(1.0, 16.0) - 1.0); + (v.clamp(-1.0, 1.0) * q).round() / q + } + _ => v.tanh(), + } +} + +fn distortion(params: &Params, buf: &mut [f32], sr: f64) { + let kind = params.e("distortionType"); + let drive = db_to_lin(params.f("drive")) as f32; + let bits = params.f("bitDepth") as f32; + let out = db_to_lin(params.f("outputGain")) as f32; + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + let fc = params.f("tone").clamp(20.0, sr * 0.49); + let a = (-2.0 * PI * fc / sr).exp() as f32; + let mut lp = [0.0f32; 2]; + for f in buf.chunks_exact_mut(2) { + for ch in 0..2 { + let x = f[ch]; + let y = distort_sample(kind, x, drive, bits); + lp[ch] = y + (lp[ch] - y) * a; + f[ch] = x * dry + lp[ch] * out * wet; + } + } +} + fn lfo(kind: u32, phase: f64) -> f64 { let ph = phase.rem_euclid(1.0); match kind { @@ -546,7 +718,10 @@ mod tests { for id in [ "ec.audio.backwards", "ec.audio.basstreble", + "ec.audio.compressor", "ec.audio.delay", + "ec.audio.distortion", + "ec.audio.gate", "ec.audio.flangechorus", "ec.audio.highlowpass", "ec.audio.modulator", @@ -679,4 +854,64 @@ mod tests { assert_eq!(b, vec![2.0, 1.0]); assert!(reverses("ec.audio.backwards")); } + + #[test] + fn compressor_reduces_gain_above_threshold() { + // Static curve: 10 dB over a −20 dB threshold at 4:1 comes out 7.5 dB lower. + assert!((compressor_gain_db(-10.0, -20.0, 4.0, 0.0) + 7.5).abs() < 1e-9); + assert_eq!(compressor_gain_db(-30.0, -20.0, 4.0, 0.0), 0.0); + // The soft knee meets the hard curve at its edges. + let k = 6.0; + assert!(compressor_gain_db(-23.0, -20.0, 4.0, k).abs() < 1e-9); + assert!((compressor_gain_db(-17.0, -20.0, 4.0, k) - compressor_gain_db(-17.0, -20.0, 4.0, 0.0)).abs() < 1e-9); + let sr = 48000.0; + let mut p = defaults("ec.audio.compressor"); + p.values.insert("attack".into(), num(1.0)); + let mut loud = sine(1000.0, sr, 9600); + process("ec.audio.compressor", &p, &mut loud, 48000, 0.0); + // A full-scale sine (peak 0 dB) is pulled down by ~15 dB once the envelope settles. + let peak = loud[18000..].iter().fold(0.0f32, |m, v| m.max(v.abs())); + let want = db_to_lin(compressor_gain_db(0.0, -20.0, 4.0, 0.0)) as f32; + assert!((peak - want).abs() < 0.05, "{peak} vs {want}"); + // Quiet input passes untouched. + let mut quiet: Vec = sine(1000.0, sr, 4800).iter().map(|v| v * 0.01).collect(); + let orig = quiet.clone(); + process("ec.audio.compressor", &p, &mut quiet, 48000, 0.0); + assert!(quiet.iter().zip(&orig).all(|(a, b)| (a - b).abs() < 1e-6)); + } + + #[test] + fn gate_mutes_below_threshold() { + let sr = 48000.0; + let p = defaults("ec.audio.gate"); + // A −60 dB signal under the −40 dB threshold is attenuated by the −80 dB range. + let mut quiet: Vec = sine(500.0, sr, 9600).iter().map(|v| v * 0.001).collect(); + process("ec.audio.gate", &p, &mut quiet, 48000, 0.0); + assert!(rms(&quiet, 4800) < 1e-6, "{}", rms(&quiet, 4800)); + // A loud signal opens the gate. + let mut loud: Vec = sine(500.0, sr, 9600).iter().map(|v| v * 0.5).collect(); + let r0 = rms(&loud, 4800); + process("ec.audio.gate", &p, &mut loud, 48000, 0.0); + assert!((rms(&loud, 4800) - r0).abs() < 0.01); + // Deterministic. + let mut a = sine(300.0, sr, 2000); + let mut b = a.clone(); + process("ec.audio.gate", &p, &mut a, 48000, 0.0); + process("ec.audio.gate", &p, &mut b, 48000, 0.0); + assert_eq!(a, b); + } + + #[test] + fn distortion_shapes_and_bounds() { + assert!((distort_sample(0, 0.5, 1.0, 8.0) - 0.5f32.tanh()).abs() < 1e-6); + assert_eq!(distort_sample(1, 0.8, 2.0, 8.0), 1.0); + assert!((distort_sample(2, 0.7, 2.0, 8.0) - 0.6).abs() < 1e-5); + assert_eq!(distort_sample(3, 0.3, 1.0, 2.0), 0.5); + let p = defaults("ec.audio.distortion"); + let mut b = sine(200.0, 48000.0, 4800); + process("ec.audio.distortion", &p, &mut b, 48000, 0.0); + assert!(b.iter().all(|v| v.abs() <= 1.0)); + assert!(rms(&b, 100) > 0.1); + assert!(preroll("ec.audio.compressor", &defaults("ec.audio.compressor")) > 0.4); + } } diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index dfd49ccc..a249955f 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -174,7 +174,17 @@ fn blocks_match_one_long_mix() { let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32; (x, -x) } - for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] { + for id in [ + "ec.audio.reverb", + "ec.audio.basstreble", + "ec.audio.flangechorus", + "ec.audio.modulator", + "ec.audio.delay", + "ec.audio.parametriceq", + "ec.audio.compressor", + "ec.audio.gate", + "ec.audio.distortion", + ] { let vals: Vec<(&str, Value)> = match id { "ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))], "ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))], From fe815888672b77ef44f9bc4d7c3b7a078408da72 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 20:46:53 -0400 Subject: [PATCH 02/10] M9.11: OCIO transforms, Color Stabilizer and Color Profile Converter Built-in minimal OCIO config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published primaries, transfer functions and Bradford adaptation; CDL v1.2 maths; .cube/.3dl/.csp and .cc/.ccc/.cdl file transforms with nearest/trilinear/tetrahedral interpolation and inverse; displays/views/looks. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 6 + crates/effects/src/ocio.rs | 1281 +++++++++++++++++++++++++++++++++ crates/effects/src/utility.rs | 55 +- crates/render/src/lib.rs | 11 +- 4 files changed, 1349 insertions(+), 4 deletions(-) create mode 100644 crates/effects/src/ocio.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2e29061a..4bb658c3 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -27,6 +27,7 @@ mod misc; mod noise; mod noise2; mod obsolete; +mod ocio; pub mod paint; mod perspective; mod perspective2; @@ -245,6 +246,10 @@ pub struct EffectEnv<'a> { pub frame_rate: f64, /// Index of the running effect in its layer's stack (for [`EffectHost::self_at`]). pub effect_index: usize, + /// The project's working colour space (`None` = unmanaged, treated as sRGB). + pub working_space: Option, + /// Working-space pixels are linear light (Linearize Working Space). + pub working_linear: bool, } /// What an effect gets to render with. @@ -348,6 +353,7 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(noise2::specs()); v.extend(color3::specs()); v.extend(obsolete::specs()); + v.extend(ocio::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs new file mode 100644 index 00000000..d868a8f9 --- /dev/null +++ b/crates/effects/src/ocio.rs @@ -0,0 +1,1281 @@ +//! OCIO-style colour transforms (Color Correction ▸ OCIO CDL / Color Space / Display / File / Look +//! Transform), Color Profile Converter (Utility) and Color Stabilizer (Color Correction). +//! +//! The OCIO effects run on a **built-in minimal configuration** written from published +//! standards, not from OpenColorIO's source: +//! +//! * colour spaces are defined by primaries, white point and transfer function: ACES2065-1 (AP0) +//! and ACEScg / ACEScct (AP1) from SMPTE ST 2065-1 and Academy S-2014-004 / S-2016-001, sRGB +//! (IEC 61966-2-1), Rec. 709 / Rec. 2020 with the BT.1886 gamma 2.4 EOTF, Display P3 (SMPTE +//! EG 432-1 primaries, D65, sRGB curve), their linear variants and CIE XYZ D65; +//! * the reference space is CIE XYZ D65; spaces with another white point (the ACES white, +//! x 0.32168 y 0.33767) are adapted with the Bradford chromatic adaptation transform, as in the +//! published ACES configurations; +//! * displays (sRGB, Rec.1886 Rec.709, Display P3, Rec.2100-PQ via SMPTE ST 2084) and views +//! (Standard = clip-free encode, Tone Mapped = extended Reinhard roll-off, Raw); +//! * looks are ASC CDLs applied in ACEScct; +//! * CDL maths follows the ASC CDL v1.2 specification (slope, offset, power, then saturation with +//! Rec. 709 luma weights); the "No Clamp" style mirrors power around zero; +//! * File Transform reads `.cube` (Adobe/Resolve), `.3dl` (Autodesk Lustre/Flame integer +//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper) and ASC `.cc` / `.ccc` / +//! `.cdl` XML, with nearest, trilinear or tetrahedral interpolation and an inverse direction +//! (exact for 1D/CDL, iterative for 3D lattices). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_color::ColorSpace; +use effectcraft_color::space::{invert, mul, mul_vec, rgb_to_xyz}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::unpremul; +use crate::utility::{Lut, parse_cube}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +type M3 = [[f64; 3]; 3]; + +const D65: [f64; 2] = [0.3127, 0.3290]; +const ACES_WHITE: [f64; 2] = [0.32168, 0.33767]; +const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]]; +const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]]; +const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; +const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; +const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; +const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// Transfer functions (encoded ↔ linear). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Tf { + Linear, + Srgb, + Gamma(f64), + AcesCct, + /// SMPTE ST 2084 with 1.0 = 100 cd/m². + Pq, +} + +impl Tf { + pub fn decode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) }; + l.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(g).copysign(v), + Tf::AcesCct => { + if v <= 0.155_251_141_552_511 { + (v - 0.072_905_534_195_835_5) / 10.540_237_741_654_5 + } else if v < (65504f64.log2() + 9.72) / 17.52 { + 2f64.powf(v * 17.52 - 9.72) + } else { + 65504.0 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let e = v.max(0.0).powf(1.0 / m2); + let l = ((e - c1).max(0.0) / (c2 - c3 * e)).powf(1.0 / m1); + l * 100.0 + } + } + } + pub fn encode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let e = if a <= 0.003_130_8 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 }; + e.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(1.0 / g).copysign(v), + Tf::AcesCct => { + if v <= 0.007_812_5 { + 10.540_237_741_654_5 * v + 0.072_905_534_195_835_5 + } else { + (v.log2() + 9.72) / 17.52 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let y = (v / 100.0).max(0.0).powf(m1); + ((c1 + c2 * y) / (1.0 + c3 * y)).powf(m2) + } + } + } +} + +fn pq_consts() -> (f64, f64, f64, f64, f64) { + (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0, 3424.0 / 4096.0, 2413.0 / 4096.0 * 32.0, 2392.0 / 4096.0 * 32.0) +} + +/// A colour space of the built-in config. +#[derive(Clone, Copy, Debug)] +pub struct Space { + pub name: &'static str, + /// Primaries and white (None = CIE XYZ itself). + pub prims: Option<([[f64; 2]; 3], [f64; 2])>, + pub tf: Tf, + /// Passes data through untouched. + pub raw: bool, +} + +const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space { + Space { name, prims: Some((prims, white)), tf, raw: false } +} + +/// The built-in configuration's colour spaces, in popup order. +pub const SPACES: &[Space] = &[ + sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear), + sp("ACEScg", AP1, ACES_WHITE, Tf::Linear), + sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct), + sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear), + sp("sRGB", REC709, D65, Tf::Srgb), + sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)), + sp("Linear Rec.2020", REC2020, D65, Tf::Linear), + sp("Gamma 2.4 Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + sp("Linear P3-D65", P3, D65, Tf::Linear), + sp("Display P3", P3, D65, Tf::Srgb), + Space { name: "CIE-XYZ-D65", prims: None, tf: Tf::Linear, raw: false }, + Space { name: "Raw", prims: None, tf: Tf::Linear, raw: true }, +]; + +pub fn space_names() -> Vec<&'static str> { + SPACES.iter().map(|s| s.name).collect() +} + +pub fn space_by_name(n: &str) -> Option<&'static Space> { + SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n)) +} + +/// Bradford chromatic adaptation from white `src` to white `dst` (xy). +pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> M3 { + if src == dst { + return IDENTITY; + } + const MB: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let s = mul_vec(&MB, xyz(src)); + let d = mul_vec(&MB, xyz(dst)); + let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; + mul(&invert(&MB), &mul(&diag, &MB)) +} + +impl Space { + /// Linear RGB → XYZ D65 (Bradford-adapted when `adapt`, else the space's own white). + pub fn to_ref(self, adapt: bool) -> M3 { + match self.prims { + None => IDENTITY, + Some((p, w)) => { + let m = rgb_to_xyz(p, w); + if adapt { mul(&bradford(w, D65), &m) } else { m } + } + } + } +} + +/// Linear-RGB matrix from space `a` to space `b`. +pub fn matrix(a: &Space, b: &Space) -> M3 { + mul(&invert(&b.to_ref(true)), &a.to_ref(true)) +} + +/// A compiled colour-space conversion. +#[derive(Clone, Copy, Debug)] +pub struct Xform { + dec: Tf, + m: [[f32; 3]; 3], + ident: bool, + enc: Tf, +} + +impl Xform { + pub fn new(a: &Space, b: &Space) -> Xform { + if a.raw || b.raw { + return Xform { dec: Tf::Linear, m: IDENTITY.map(|r| r.map(|v| v as f32)), ident: true, enc: Tf::Linear }; + } + let m = matrix(a, b); + let ident = (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-12)); + Xform { dec: a.tf, m: m.map(|r| r.map(|v| v as f32)), ident, enc: b.tf } + } + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let l = c.map(|v| self.dec.decode(v as f64) as f32); + let l = if self.ident { l } else { [0, 1, 2].map(|i| self.m[i][0] * l[0] + self.m[i][1] * l[1] + self.m[i][2] * l[2]) }; + l.map(|v| self.enc.encode(v as f64) as f32) + } +} + +// ---------------------------------------------------------------- ASC CDL + +/// An ASC Colour Decision List correction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Cdl { + pub slope: [f64; 3], + pub offset: [f64; 3], + pub power: [f64; 3], + pub sat: f64, +} + +impl Default for Cdl { + fn default() -> Self { + Cdl { slope: [1.0; 3], offset: [0.0; 3], power: [1.0; 3], sat: 1.0 } + } +} + +const LUMA: [f64; 3] = [0.2126, 0.7152, 0.0722]; + +impl Cdl { + /// Forward CDL. `clamp` = the v1.2 style (results clamped to 0..1 after SOP and saturation). + pub fn apply(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { + let mut o = [0.0; 3]; + for i in 0..3 { + let v = c[i] * self.slope[i] + self.offset[i]; + o[i] = if clamp { + v.clamp(0.0, 1.0).powf(self.power[i]) + } else if v >= 0.0 { + v.powf(self.power[i]) + } else { + v + }; + } + let l = o[0] * LUMA[0] + o[1] * LUMA[1] + o[2] * LUMA[2]; + let o = o.map(|v| l + self.sat * (v - l)); + if clamp { o.map(|v| v.clamp(0.0, 1.0)) } else { o } + } + /// Exact inverse of [`Cdl::apply`] (inside the clamped range). + pub fn invert(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { + let c = if clamp { c.map(|v| v.clamp(0.0, 1.0)) } else { c }; + let l = c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2]; + let s = if self.sat.abs() < 1e-9 { 1e-9 } else { self.sat }; + let o = c.map(|v| l + (v - l) / s); + let mut r = [0.0; 3]; + for i in 0..3 { + let v = if clamp { o[i].clamp(0.0, 1.0) } else { o[i] }; + let pw = self.power[i].max(1e-9); + let u = if v >= 0.0 { v.powf(1.0 / pw) } else { v }; + let sl = if self.slope[i].abs() < 1e-9 { 1e-9 } else { self.slope[i] }; + r[i] = (u - self.offset[i]) / sl; + } + if clamp { r.map(|v| v.clamp(0.0, 1.0)) } else { r } + } +} + +fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let open = xml.find(&format!("<{name}"))?; + let gt = open + xml[open..].find('>')?; + if xml[..gt].ends_with('/') { + return Some(""); + } + let close = xml[gt..].find(&format!(""))? + gt; + Some(&xml[gt + 1..close]) +} + +fn nums3(s: &str) -> Option<[f64; 3]> { + let v: Vec = s.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + (v.len() >= 3).then(|| [v[0], v[1], v[2]]) +} + +fn parse_cc(block: &str) -> Cdl { + let mut c = Cdl::default(); + if let Some(s) = tag(block, "Slope").and_then(nums3) { + c.slope = s; + } + if let Some(s) = tag(block, "Offset").and_then(nums3) { + c.offset = s; + } + if let Some(s) = tag(block, "Power").and_then(nums3) { + c.power = s; + } + if let Some(s) = tag(block, "Saturation").and_then(|t| t.trim().parse().ok()) { + c.sat = s; + } + c +} + +/// Parse ASC CDL XML (`.cc`, `.ccc`, `.cdl`): every `` with its `id`. +pub fn parse_cdl_xml(xml: &str) -> Vec<(String, Cdl)> { + let mut out = vec![]; + let mut rest = xml; + while let Some(i) = rest.find("').unwrap_or(after.len()); + let head = &after[..head_end]; + let id = head + .find("id=") + .and_then(|k| { + let q = head[k + 3..].chars().next()?; + let s = &head[k + 4..]; + s.find(q).map(|e| s[..e].to_string()) + }) + .unwrap_or_default(); + let end = after.find("").map(|e| e + "".len()).unwrap_or(after.len()); + out.push((id, parse_cc(&after[..end]))); + rest = &after[end..]; + } + out +} + +// ---------------------------------------------------------------- LUT files + +/// A loaded transform file. +#[derive(Clone, Debug)] +pub enum FileXform { + /// A lattice/1D LUT with an optional per-channel piecewise-linear shaper (cineSpace pre-LUT). + Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> }, + /// CDL corrections by id (in file order). + Cdl(Vec<(String, Cdl)>), +} + +/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line). +pub fn parse_3dl(text: &str) -> Option { + let mut mesh: Option> = None; + let mut rows: Vec<[f64; 3]> = vec![]; + let mut out_bits: Option = None; + for line in text.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') || l.starts_with('<') { + continue; + } + if l.starts_with(|c: char| c.is_ascii_alphabetic()) { + // "Mesh " (Lustre header); other keywords are ignored. + let t: Vec<&str> = l.split_whitespace().collect(); + if t[0].eq_ignore_ascii_case("mesh") && t.len() >= 3 { + out_bits = t[2].parse().ok(); + } + continue; + } + let v: Vec = l.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if v.len() > 3 && mesh.is_none() && rows.is_empty() { + mesh = Some(v); + } else if v.len() == 3 { + rows.push([v[0], v[1], v[2]]); + } else { + return None; + } + } + let is_cube = |k: usize| { + let n = (k as f64).cbrt().round() as usize; + n * n * n == k + }; + // A 3-entry input mesh (3³ lattice) looks like a data row: tell them apart by the count. + if mesh.is_none() && !is_cube(rows.len()) && rows.len() == 28 { + mesh = Some(rows.remove(0).to_vec()); + } + let n = match &mesh { + Some(m) => m.len(), + None => (rows.len() as f64).cbrt().round() as usize, + }; + if n < 2 || rows.len() != n * n * n { + return None; + } + let maxv = rows.iter().flat_map(|r| r.iter()).fold(0.0f64, |m, &v| m.max(v)); + let out_max = match out_bits { + Some(b) if b > 0 && b < 32 => (1u64 << b) as f64 - 1.0, + _ => [255.0, 1023.0, 4095.0, 65535.0].into_iter().find(|&m| maxv <= m).unwrap_or(maxv.max(1.0)), + }; + // .3dl rows: red slowest, blue fastest. Our lattice is red fastest. + let mut d3 = vec![[0.0f32; 3]; n * n * n]; + for (k, r) in rows.iter().enumerate() { + let (ri, gi, bi) = (k / (n * n), (k / n) % n, k % n); + d3[ri + gi * n + bi * n * n] = r.map(|v| (v / out_max) as f32); + } + Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) +} + +/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers). +pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + if !lines.next()?.starts_with("CSPLUTV100") { + return None; + } + let kind = lines.next()?; + let mut lines = lines.peekable(); + if lines.peek().is_some_and(|l| l.starts_with("BEGIN METADATA")) { + for l in lines.by_ref() { + if l.starts_with("END METADATA") { + break; + } + } + } + let nums = |l: &str| -> Option> { l.split_whitespace().map(|t| t.parse().ok()).collect() }; + let mut shaper: [Vec<(f32, f32)>; 3] = Default::default(); + for ch in shaper.iter_mut() { + let n: usize = lines.next()?.parse().ok()?; + let ins = nums(lines.next()?)?; + let outs = nums(lines.next()?)?; + if ins.len() != n || outs.len() != n || n < 2 { + return None; + } + *ch = ins.into_iter().zip(outs).collect(); + } + if kind.starts_with("3D") { + let sz = nums(lines.next()?)?; + if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] { + return None; + } + let n = sz[0] as usize; + let mut d3 = Vec::with_capacity(n * n * n); + for _ in 0..n * n * n { + let v = nums(lines.next()?)?; + if v.len() != 3 { + return None; + } + d3.push([v[0], v[1], v[2]]); + } + (n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) + } else { + let n: usize = lines.next()?.parse().ok()?; + let mut d1 = Vec::with_capacity(n); + for _ in 0..n { + let v = nums(lines.next()?)?; + if v.len() != 3 { + return None; + } + d1.push([v[0], v[1], v[2]]); + } + (n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) + } +} + +fn shape(curve: &[(f32, f32)], x: f32) -> f32 { + if curve.len() < 2 { + return x; + } + let i = curve.partition_point(|p| p.0 < x).clamp(1, curve.len() - 1); + let (a, b) = (curve[i - 1], curve[i]); + let t = ((x - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0); + a.1 + (b.1 - a.1) * t +} + +fn shape_inv(curve: &[(f32, f32)], y: f32) -> f32 { + let flipped: Vec<(f32, f32)> = curve.iter().map(|&(a, b)| (b, a)).collect(); + shape(&flipped, y) +} + +/// Parse transform text by its format (detected from `ext` or the content). +pub fn parse_file(text: &str, ext: &str) -> Option { + let ext = ext.to_ascii_lowercase(); + if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains(">>>; + +/// Load (and cache) a transform from a file path or inline content. +pub fn load_file(src: &str) -> Option> { + let src_t = src.trim(); + if src_t.is_empty() { + return None; + } + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(m) = cache.lock() + && let Some(v) = m.get(src) + { + return v.clone(); + } + let inline = src.contains('\n'); + let parsed = if inline { + parse_file(src, "") + } else { + let ext = std::path::Path::new(src_t).extension().and_then(|e| e.to_str()).unwrap_or(""); + std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext)) + } + .map(Arc::new); + if let Ok(mut m) = cache.lock() { + m.insert(src.to_string(), parsed.clone()); + } + parsed +} + +impl FileXform { + pub fn apply(&self, c: [f32; 3], interp: u32, inverse: bool, ccc_id: &str) -> [f32; 3] { + match self { + FileXform::Cdl(list) => { + let cdl = list.iter().find(|(id, _)| !ccc_id.is_empty() && id == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); + let c64 = c.map(|v| v as f64); + let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) }; + o.map(|v| v as f32) + } + FileXform::Lut { lut, shaper } => { + let fwd = |c: [f32; 3]| { + let c = match shaper { + Some(s) => [shape(&s[0], c[0]), shape(&s[1], c[1]), shape(&s[2], c[2])], + None => c, + }; + lut.apply_interp(c, interp) + }; + if !inverse { + return fwd(c); + } + if lut.n3 == 0 { + // 1D: invert each channel exactly through the (monotone) tables. + let mut x = c; + if lut.n1 > 1 { + x = [0, 1, 2].map(|i| { + let curve: Vec<(f32, f32)> = + (0..lut.n1).map(|k| (lut.d1[k][i], lut.dmin[i] + (lut.dmax[i] - lut.dmin[i]) * k as f32 / (lut.n1 - 1) as f32)).collect(); + shape(&curve, c[i]) + }); + } + if let Some(s) = shaper { + x = [shape_inv(&s[0], x[0]), shape_inv(&s[1], x[1]), shape_inv(&s[2], x[2])]; + } + return x; + } + // 3D: damped fixed-point (Newton with an identity Jacobian) from the target. + let mut x = c; + for _ in 0..40 { + let y = fwd(x); + let e = [c[0] - y[0], c[1] - y[1], c[2] - y[2]]; + if e.iter().all(|v| v.abs() < 1e-6) { + break; + } + x = [x[0] + e[0], x[1] + e[1], x[2] + e[2]]; + } + x + } + } + } +} + +// ---------------------------------------------------------------- displays, views and looks + +/// Displays of the built-in config: (name, display primaries/white, encoding). +pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] = + &[("sRGB", REC709, Tf::Srgb), ("Rec.1886 Rec.709", REC709, Tf::Gamma(2.4)), ("Display P3", P3, Tf::Srgb), ("Rec.2100-PQ", REC2020, Tf::Pq)]; + +pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"]; + +/// Built-in looks: CDLs applied in ACEScct. +pub fn looks() -> Vec<(&'static str, Cdl)> { + let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18 + let c = |slope: [f64; 3], offset: [f64; 3], sat: f64| Cdl { slope, offset, power: [1.0; 3], sat }; + vec![ + ("Warm", c([1.04, 1.0, 0.94], [0.0; 3], 1.0)), + ("Cool", c([0.95, 1.0, 1.06], [0.0; 3], 1.0)), + ("High Contrast", c([1.25; 3], [grey * (1.0 - 1.25); 3], 1.0)), + ("Low Contrast", c([0.8; 3], [grey * (1.0 - 0.8); 3], 1.0)), + ("Desaturated", c([1.0; 3], [0.0; 3], 0.6)), + ("Vivid", c([1.0; 3], [0.0; 3], 1.3)), + ] +} + +/// Extended Reinhard roll-off (white point 16): maps 0..∞ into 0..1, ≈ identity near 0. +fn tonemap(v: f64) -> f64 { + let w2 = 256.0; + if v <= 0.0 { v } else { v * (1.0 + v / w2) / (1.0 + v) } +} + +fn tonemap_inv(y: f64) -> f64 { + if y <= 0.0 { + return y; + } + // Solve v² / w² + v (1 − y) − y = 0. + let (a, b, c) = (1.0 / 256.0, 1.0 - y, -y); + (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a) +} + +/// Display transform of an input-space colour (`inverse`: display code values → input space). +pub fn display_xform(input: &Space, display: usize, view: usize, inverse: bool) -> impl Fn([f32; 3]) -> [f32; 3] + Sync { + let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; + let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; + let raw = view == 2 || input.raw; + let m_in = Xform::new(&Space { tf: Tf::Linear, ..*input }, &disp); + let m_out = Xform::new(&disp, &Space { tf: Tf::Linear, ..*input }); + let in_tf = input.tf; + move |c: [f32; 3]| { + if raw { + return c; + } + if !inverse { + let lin = c.map(|v| in_tf.decode(v as f64) as f32); + let d = m_in.apply(lin); + d.map(|v| { + let v = v as f64; + let v = if view == 1 { tonemap(v) } else { v }; + tf.encode(v) as f32 + }) + } else { + let d = c.map(|v| { + let l = tf.decode(v as f64); + (if view == 1 { tonemap_inv(l) } else { l }) as f32 + }); + m_out.apply(d).map(|v| in_tf.encode(v as f64) as f32) + } + } +} + +// ---------------------------------------------------------------- effects + +fn spec(id: &'static str, name: &'static str, cat: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: cat, params, render, gpu: false, float: true } +} + +fn space_param(id: &'static str, name: &'static str, default: u32) -> crate::ParamSpec { + p(id, name, Value::Enum(default), popup(&space_names())) +} + +fn direction() -> crate::ParamSpec { + p("direction", "Direction", Value::Enum(0), popup(&["Forward", "Inverse"])) +} + +fn map_px(b: &mut Buf, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) { + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let o = f(c); + *px = [o[0] * a, o[1] * a, o[2] * a, a]; + }); +} + +fn space_at(i: u32) -> &'static Space { + &SPACES[(i as usize).min(SPACES.len() - 1)] +} + +fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); + let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; + if a.name == z.name { + return b; + } + let x = Xform::new(a, z); + map_px(&mut b, |c| x.apply(c)); + b +} + +fn cdl_from_params(pr: &crate::Params) -> Cdl { + Cdl { + slope: [pr.f("slopeRed"), pr.f("slopeGreen"), pr.f("slopeBlue")], + offset: [pr.f("offsetRed"), pr.f("offsetGreen"), pr.f("offsetBlue")], + power: [pr.f("powerRed").max(0.0), pr.f("powerGreen").max(0.0), pr.f("powerBlue").max(0.0)], + sat: pr.f("saturation").max(0.0), + } +} + +fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf { + let cdl = cdl_from_params(ctx.params); + if cdl == Cdl::default() { + return b; + } + let clamp = ctx.params.e("style") == 0; + let inv = ctx.params.e("direction") == 1; + map_px(&mut b, |c| { + let c = c.map(|v| v as f64); + let o = if inv { cdl.invert(c, clamp) } else { cdl.apply(c, clamp) }; + o.map(|v| v as f32) + }); + b +} + +fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf { + let input = space_at(ctx.params.e("source")); + let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1); + map_px(&mut b, f); + b +} + +fn ocio_file(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(x) = load_file(ctx.params.s("file")) else { return b }; + let interp = match ctx.params.e("interpolation") { + 0 => 0, + 1 => 1, + _ => 2, + }; + let inv = ctx.params.e("direction") == 1; + let id = ctx.params.s("cccId").to_string(); + map_px(&mut b, |c| x.apply(c, interp, inv, &id)); + b +} + +/// Parse a looks string: comma/space separated names, `-Name` = inverse. +pub fn parse_looks(s: &str) -> Vec<(Cdl, bool)> { + let all = looks(); + s.split([',', ':']) + .map(str::trim) + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let (inv, n) = match t.strip_prefix('-') { + Some(r) => (true, r.trim()), + None => (false, t.trim_start_matches('+').trim()), + }; + all.iter().find(|(name, _)| name.eq_ignore_ascii_case(n)).map(|(_, c)| (*c, inv)) + }) + .collect() +} + +fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mut list = parse_looks(ctx.params.s("looks")); + let pick = ctx.params.e("look"); + if pick > 0 + && let Some((_, c)) = looks().get(pick as usize - 1) + { + list.insert(0, (*c, false)); + } + if list.is_empty() { + return b; + } + let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); + let inv = ctx.params.e("direction") == 1; + let cct = space_by_name("ACEScct").expect("built-in"); + let (a, z) = if inv { (dst, src) } else { (src, dst) }; + let to_p = Xform::new(a, cct); + let from_p = Xform::new(cct, z); + if inv { + list.reverse(); + } + map_px(&mut b, |c| { + let mut v = to_p.apply(c).map(|x| x as f64); + for (cdl, li) in &list { + v = if *li != inv { cdl.invert(v, false) } else { cdl.apply(v, false) }; + } + from_p.apply(v.map(|x| x as f32)) + }); + b +} + +// ---- Color Profile Converter (Utility) + +pub const PROFILES: &[&str] = &["Project Working Space", "sRGB IEC61966-2.1", "HDTV (Rec. 709)", "Rec. 2020", "Display P3", "ACEScg", "ACES2065-1"]; + +fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) { + let ws = |cs: ColorSpace| match cs { + ColorSpace::Srgb => sp("sRGB", REC709, D65, Tf::Srgb), + ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)), + ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb), + }; + match i { + 0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear), + 1 => (ws(ColorSpace::Srgb), false), + 2 => (ws(ColorSpace::Rec709), false), + 3 => (ws(ColorSpace::Rec2020), false), + 4 => (ws(ColorSpace::DisplayP3), false), + 5 => (*space_by_name("ACEScg").expect("built-in"), true), + _ => (*space_by_name("ACES2065-1").expect("built-in"), true), + } +} + +/// Gamut compression for the perceptual intent: pull out-of-gamut (negative) colours toward +/// their luminance just enough to bring every channel to ≥ 0, rolling off smoothly. +fn compress_gamut(c: [f64; 3], luma: [f64; 3]) -> [f64; 3] { + let l = c[0] * luma[0] + c[1] * luma[1] + c[2] * luma[2]; + if l <= 0.0 { + return c.map(|v| v.max(0.0)); + } + let mn = c.iter().cloned().fold(f64::INFINITY, f64::min); + if mn >= 0.0 { + return c; + } + // Fraction of the way toward grey that reaches zero on the most negative channel. + let t = (l / (l - mn)).clamp(0.0, 1.0); + c.map(|v| l + (v - l) * t) +} + +fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (inp, in_lin_default) = profile(ctx, ctx.params.e("inputProfile")); + let (out, out_lin_default) = profile(ctx, ctx.params.e("outputProfile")); + let in_lin = ctx.params.b("linearizeInputProfile") || in_lin_default && ctx.params.e("inputProfile") == 0; + let out_lin = ctx.params.b("linearizeOutputProfile") || out_lin_default && ctx.params.e("outputProfile") == 0; + let intent = ctx.params.e("intent"); + let dec = if in_lin { Tf::Linear } else { inp.tf }; + let enc = if out_lin { Tf::Linear } else { out.tf }; + // Absolute colorimetric keeps the source white (no adaptation); the others adapt. + let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; + let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); + if identity { + return b; + } + let out_luma = out.to_ref(true)[1]; + map_px(&mut b, |c| { + let l = c.map(|v| dec.decode(v as f64)); + let mut o = mul_vec(&m, l); + match intent { + 0 => o = compress_gamut(o, out_luma), + 2 => o = o.map(|v| v.max(0.0)), + _ => {} + } + o.map(|v| enc.encode(v) as f32) + }); + b +} + +// ---- Color Stabilizer + +/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. +fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] { + let (cx, cy) = b.to_px(pt); + let rr = (r * b.scale).max(0.5); + let (x0, x1) = ((cx - rr).floor().max(0.0) as i64, ((cx + rr).ceil() as i64).min(b.img.width as i64)); + let (y0, y1) = ((cy - rr).floor().max(0.0) as i64, ((cy + rr).ceil() as i64).min(b.img.height as i64)); + let mut acc = [0.0f64; 4]; + for y in y0..y1 { + for x in x0..x1 { + let p = b.img.get(x, y); + for k in 0..4 { + acc[k] += p[k] as f64; + } + } + } + if acc[3] <= 1e-9 { + return [0.0; 3]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3]] +} + +/// Monotone piecewise-quadratic curve through (0,0), three samples and (1,1) (linear between +/// them is enough for a stabiliser and stays monotone). +fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { + let mut pts = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if x > pts.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 { + pts.push((x, y)); + } + } + pts.push((1.0, 1.0)); + let i = pts.partition_point(|p| p.0 < v).clamp(1, pts.len() - 1); + let (a, c) = (pts[i - 1], pts[i]); + a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) +} + +fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(host) = ctx.env.host else { return b }; + let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(&b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + map_px(&mut b, |c| { + [0, 1, 2].map(|k| { + let v = c[k] as f64; + let o = match mode { + // Brightness: one sample (black point) — offset each channel. + 0 => v + refv[0][k] - cur[0][k], + // Levels: black and white samples — linear map per channel. + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { v + r0 - c0 } else { r0 + (v - c0) * (r1 - r0) / (c1 - c0) } + } + // Curves: three samples — a curve through them per channel. + _ => curve3([cur[0][k], cur[1][k], cur[2][k]], [refv[0][k], refv[1][k], refv[2][k]], v), + }; + o as f32 + }) + }); + b +} + +fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi { + slider(lo, hi, smin, smax, 3) +} + +pub fn specs() -> Vec { + let cc = "Color Correction"; + let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap_or(0) as u32; + let lin709 = SPACES.iter().position(|s| s.name == "Linear Rec.709 (sRGB)").unwrap_or(0) as u32; + let acescg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap_or(0) as u32; + let mut look_opts = vec!["None"]; + look_opts.extend(looks().iter().map(|(n, _)| *n)); + vec![ + spec( + "ec.color.ociocdl", + "OCIO CDL Transform", + cc, + vec![ + p("slopeRed", "Slope Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("slopeGreen", "Slope Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("slopeBlue", "Slope Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("offsetRed", "Offset Red", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("offsetGreen", "Offset Green", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("offsetBlue", "Offset Blue", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("powerRed", "Power Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("powerGreen", "Power Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("powerBlue", "Power Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("saturation", "Saturation", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("style", "Style", Value::Enum(0), popup(&["ASC CDL v1.2", "No Clamp"])), + direction(), + ], + ocio_cdl, + ), + spec( + "ec.color.ociocolorspace", + "OCIO Color Space Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + space_param("source", "Source", lin709), + space_param("destination", "Destination", srgb), + direction(), + ], + ocio_cst, + ), + spec( + "ec.color.ociodisplay", + "OCIO Display Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + space_param("source", "Source", lin709), + p("display", "Display", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), + p("view", "View", Value::Enum(0), popup(VIEWS)), + direction(), + ], + ocio_display, + ), + // `file`: a path to a .cube/.3dl/.csp/.cc/.ccc/.cdl file, or the file's text itself. + spec( + "ec.color.ociofile", + "OCIO File Transform", + cc, + vec![ + p("file", "File", Value::Str(String::new()), ParamUi::Text), + p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), + p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), + direction(), + ], + ocio_file, + ), + spec( + "ec.color.ociolook", + "OCIO Look Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + space_param("source", "Source", acescg), + space_param("destination", "Destination", acescg), + p("look", "Look", Value::Enum(0), popup(&look_opts)), + p("looks", "Looks", Value::Str(String::new()), ParamUi::Text), + direction(), + ], + ocio_look, + ), + spec( + "ec.color.colorstabilizer", + "Color Stabilizer", + cc, + vec![ + p("stabilize", "Stabilize", Value::Enum(0), popup(&["Brightness", "Levels", "Curves"])), + p("referenceFrame", "Reference Frame (s)", num(0.0), slider(-100000.0, 100000.0, 0.0, 60.0, 2)), + p("blackPoint", "Black Point", Value::Vec2([0.25, 0.75]), ParamUi::Point), + p("midPoint", "Mid Point", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("whitePoint", "White Point", Value::Vec2([0.75, 0.25]), ParamUi::Point), + p("sampleSize", "Sample Size", num(5.0), slider(1.0, 100.0, 1.0, 50.0, 0)), + ], + color_stabilizer, + ), + spec( + "ec.utility.colorprofileconverter", + "Color Profile Converter", + "Utility", + vec![ + p("inputProfile", "Input Profile", Value::Enum(0), popup(PROFILES)), + p("linearizeInputProfile", "Linearize Input Profile", Value::Bool(false), ParamUi::Checkbox), + p("outputProfile", "Output Profile", Value::Enum(1), popup(PROFILES)), + p("linearizeOutputProfile", "Linearize Output Profile", Value::Bool(false), ParamUi::Checkbox), + p("intent", "Intent", Value::Enum(1), popup(&["Perceptual", "Relative Colorimetric", "Saturation", "Absolute Colorimetric"])), + ], + profile_converter, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, Image, LayerPixels, run_fx}; + + fn close(a: M3, b: M3, tol: f64) { + for i in 0..3 { + for j in 0..3 { + assert!((a[i][j] - b[i][j]).abs() < tol, "{a:?} vs {b:?}"); + } + } + } + + #[test] + fn aces_matrices_match_published_values() { + let ap0 = space_by_name("ACES2065-1").unwrap(); + let ap1 = space_by_name("ACEScg").unwrap(); + // Academy S-2014-004 AP0 → AP1. + close( + matrix(ap0, ap1), + [ + [1.451_439_316_1, -0.236_510_746_9, -0.214_928_569_3], + [-0.076_553_773_4, 1.176_229_699_8, -0.099_675_926_4], + [0.008_316_148_4, -0.006_032_449_8, 0.997_716_301_4], + ], + 2e-4, + ); + // ACEScg → linear Rec.709 with Bradford adaptation (published ACES config value). + let lin = space_by_name("Linear Rec.709 (sRGB)").unwrap(); + close(matrix(ap1, lin), [[1.705_05, -0.621_79, -0.083_26], [-0.130_26, 1.140_80, -0.010_55], [-0.024_00, -0.128_97, 1.152_97]], 2e-3); + // White maps to white. + let w = mul_vec(&matrix(ap1, lin), [1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}"); + } + + #[test] + fn transfer_functions_round_trip() { + for tf in [Tf::Srgb, Tf::Gamma(2.4), Tf::AcesCct, Tf::Pq] { + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let r = tf.decode(tf.encode(v)); + assert!((r - v).abs() < 1e-6 * v.max(1.0), "{tf:?} {v} {r}"); + } + } + // ACEScct of 18 % grey (S-2016-001). + assert!((Tf::AcesCct.encode(0.18) - 0.413_588).abs() < 1e-5); + // PQ: 100 nits (1.0) encodes to ≈ 0.508. + assert!((Tf::Pq.encode(1.0) - 0.508).abs() < 1e-3); + } + + #[test] + fn color_space_transform_effect_round_trips() { + let img = Image::filled(4, 4, [0.2, 0.5, 0.7, 1.0]); + let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap() as u32; + let cg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap() as u32; + let a = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg))], img.clone(), 0.0, EffectEnv::default()); + assert_ne!(a.img.data, img.data); + let b = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(cg)), ("destination", Value::Enum(srgb))], a.img.clone(), 0.0, EffectEnv::default()); + let c = run_fx( + "ec.color.ociocolorspace", + &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg)), ("direction", Value::Enum(1))], + a.img, + 0.0, + EffectEnv::default(), + ); + for (x, y) in b.img.data.iter().zip(&img.data).chain(c.img.data.iter().zip(&img.data)) { + assert!((0..4).all(|k| (x[k] - y[k]).abs() < 1e-4), "{x:?} {y:?}"); + } + } + + #[test] + fn cdl_math() { + let cdl = Cdl { slope: [1.2, 1.0, 0.8], offset: [0.05, 0.0, -0.02], power: [1.1, 1.0, 0.9], sat: 0.8 }; + let c = [0.3, 0.5, 0.7]; + // Hand computation of the v1.2 formula. + let sop = [(0.3f64 * 1.2 + 0.05).powf(1.1), 0.5, (0.7f64 * 0.8 - 0.02).powf(0.9)]; + let l = sop[0] * 0.2126 + sop[1] * 0.7152 + sop[2] * 0.0722; + let want = sop.map(|v| l + 0.8 * (v - l)); + let got = cdl.apply(c, true); + for k in 0..3 { + assert!((got[k] - want[k]).abs() < 1e-12); + } + let back = cdl.invert(got, true); + for k in 0..3 { + assert!((back[k] - c[k]).abs() < 1e-9); + } + // v1.2 clamps, No Clamp keeps negatives. + let neg = Cdl { offset: [-0.5; 3], ..Cdl::default() }; + assert_eq!(neg.apply([0.2; 3], true), [0.0; 3]); + assert!((neg.apply([0.2; 3], false)[0] + 0.3).abs() < 1e-12); + let img = Image::filled(2, 2, [0.3, 0.5, 0.7, 1.0]); + let out = run_fx("ec.color.ociocdl", &[("slopeRed", num(1.2)), ("offsetRed", num(0.05)), ("powerRed", num(1.1))], img, 0.0, EffectEnv::default()); + let r = (0.3f64 * 1.2 + 0.05).powf(1.1); + assert!((out.img.get(0, 0)[0] as f64 - r).abs() < 1e-5); + } + + fn identity_lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Vec<[f32; 3]> { + let mut v = vec![]; + for b in 0..n { + for g in 0..n { + for r in 0..n { + let s = (n - 1) as f32; + v.push(f([r as f32 / s, g as f32 / s, b as f32 / s])); + } + } + } + v + } + + #[test] + fn lut_files_parse_and_interpolate() { + // .cube with a non-linear lattice: trilinear between lattice points. + let f = |c: [f32; 3]| [c[0] * c[0], c[1], 1.0 - c[2]]; + let mut cube = String::from("LUT_3D_SIZE 3\n"); + for r in identity_lattice(3, f) { + cube += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&cube, "cube") else { panic!() }; + // Trilinear between 0.5 (0.25) and 1.0 (1.0) at 0.75 → 0.625. + let o = lut.apply_interp([0.75, 0.3, 0.2], 1); + assert!((o[0] - 0.625).abs() < 1e-6 && (o[1] - 0.3).abs() < 1e-6 && (o[2] - 0.8).abs() < 1e-6, "{o:?}"); + // Nearest snaps. + assert_eq!(lut.apply_interp([0.74, 0.0, 0.0], 0)[0], 0.25); + // .3dl (blue fastest, 10-bit integers, mesh line). + let mut tdl = String::from("0 511 1023\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = [r, g, b].map(|i| i as f64 * 511.5); + tdl += &format!("{} {} {}\n", v[0].round(), v[1].round(), (1023.0 - v[2]).round()); + } + } + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&tdl, "3dl") else { panic!() }; + let o = lut.apply_interp([0.25, 0.5, 0.25], 1); + assert!((o[0] - 0.25).abs() < 2e-3 && (o[1] - 0.5).abs() < 2e-3 && (o[2] - 0.75).abs() < 2e-3, "{o:?}"); + // .csp with a squaring pre-LUT on red and an identity 2³ lattice. + let mut csp = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.25 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n2 2 2\n"); + for r in identity_lattice(2, |c| c) { + csp += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let x = parse_file(&csp, "csp").unwrap(); + let o = x.apply([0.5, 0.4, 0.6], 2, false, ""); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}"); + let inv = x.apply(o, 2, true, ""); + assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}"); + // CDL collection: pick by id. + let ccc = r#"2 2 20 0 01 1 1 + 0.5 0.5 0.51"#; + let x = parse_file(ccc, "ccc").unwrap(); + assert!((x.apply([0.4; 3], 2, false, "b")[0] - 0.2).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "a")[0] - 0.8).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "")[0] - 0.8).abs() < 1e-6); + // Effect with inline content; inverse of a 3D lattice. + let img = Image::filled(2, 2, [0.6, 0.3, 0.2, 1.0]); + let out = run_fx("ec.color.ociofile", &[("file", Value::Str(cube.clone()))], img.clone(), 0.0, EffectEnv::default()); + let back = run_fx("ec.color.ociofile", &[("file", Value::Str(cube)), ("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.6).abs() < 1e-3, "{:?}", back.img.get(0, 0)); + assert!(parse_file("garbage", "3dl").is_none()); + } + + #[test] + fn display_and_look_transforms() { + // Linear 0.18 through the sRGB display / Standard view = sRGB encoding of 0.18. + let img = Image::filled(2, 2, [0.18, 0.18, 0.18, 1.0]); + let out = run_fx("ec.color.ociodisplay", &[], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(0, 0)[0] as f64 - Tf::Srgb.encode(0.18)).abs() < 1e-5); + let back = run_fx("ec.color.ociodisplay", &[("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.18).abs() < 1e-5); + // Tone mapped view keeps highlights below 1. + let hot = Image::filled(1, 1, [8.0, 8.0, 8.0, 1.0]); + let tm = run_fx("ec.color.ociodisplay", &[("view", Value::Enum(1))], hot, 0.0, EffectEnv::default()); + assert!(tm.img.get(0, 0)[0] < 1.0); + assert!((tonemap_inv(tonemap(3.0)) - 3.0).abs() < 1e-9); + // Looks: Warm raises red over blue; its inverse undoes it. + let grey = Image::filled(1, 1, [0.18, 0.18, 0.18, 1.0]); + let w = run_fx("ec.color.ociolook", &[("looks", Value::Str("Warm".into()))], grey.clone(), 0.0, EffectEnv::default()); + let px = w.img.get(0, 0); + assert!(px[0] > px[2]); + let u = run_fx("ec.color.ociolook", &[("looks", Value::Str("-Warm".into()))], w.img, 0.0, EffectEnv::default()); + assert!((u.img.get(0, 0)[0] - 0.18).abs() < 1e-4 && (u.img.get(0, 0)[2] - 0.18).abs() < 1e-4); + assert_eq!(parse_looks("Warm, -Cool, nope").len(), 2); + } + + #[test] + fn profile_converter_converts_and_round_trips() { + let img = Image::filled(2, 2, [0.8, 0.4, 0.1, 1.0]); + // sRGB → Rec. 2020 (relative), then back. + let a = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(1)), ("outputProfile", Value::Enum(3))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_ne!(a.img.data, img.data); + let b = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], + a.img, + 0.0, + EffectEnv::default(), + ); + assert!((0..3).all(|k| (b.img.get(0, 0)[k] - img.get(0, 0)[k]).abs() < 1e-4)); + // Same profile both sides: identity. Working space defaults to sRGB. + let c = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(0)), ("outputProfile", Value::Enum(1))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(c.img.data, img.data); + // Perceptual pulls out-of-gamut colours inside: saturated Rec.2020 green into sRGB. + let g = Image::filled(1, 1, [0.0, 1.0, 0.0, 1.0]); + let rel = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], + g.clone(), + 0.0, + EffectEnv::default(), + ); + assert!(rel.img.get(0, 0)[0] < 0.0); + let per = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1)), ("intent", Value::Enum(0))], + g, + 0.0, + EffectEnv::default(), + ); + assert!(per.img.get(0, 0).iter().all(|v| *v >= -1e-6)); + } + + struct RefHost(Image); + impl EffectHost for RefHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + 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 }) + } + } + + #[test] + fn color_stabilizer_matches_the_reference_frame() { + let mk = |k: f32, o: f32| { + let mut img = Image::new(40, 40); + for y in 0..40 { + for x in 0..40 { + let v = (x as f32 / 40.0) * k + o; + img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]); + } + } + img + }; + let reference = mk(1.0, 0.0); + let flicker = mk(0.8, 0.1); + let host = RefHost(reference.clone()); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let pts = [("blackPoint", Value::Vec2([5.0, 20.0])), ("midPoint", Value::Vec2([20.0, 20.0])), ("whitePoint", Value::Vec2([35.0, 20.0]))]; + let mut vals: Vec<(&str, Value)> = pts.to_vec(); + vals.push(("stabilize", Value::Enum(1))); + vals.push(("sampleSize", num(1.0))); + let out = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + for x in [5i64, 20, 35] { + let (o, r) = (out.img.get(x, 20), reference.get(x, 20)); + assert!((o[0] - r[0]).abs() < 0.01, "x {x}: {o:?} vs {r:?}"); + } + // Deterministic, and a no-op without a host. + let again = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flicker.data); + // Brightness mode corrects an offset at the black point. + let lifted = mk(1.0, 0.1); + let mut vals2: Vec<(&str, Value)> = pts.to_vec(); + vals2.push(("sampleSize", num(1.0))); + let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env); + assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01); + } +} diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 49b7ed36..7689050d 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -153,7 +153,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Colour LUTs (.cube) ---- #[derive(Clone, Debug, PartialEq)] -pub(crate) struct Lut { +pub struct Lut { pub n1: usize, pub d1: Vec<[f32; 3]>, pub n3: usize, @@ -163,7 +163,7 @@ pub(crate) struct Lut { } /// Parse `.cube` text (TITLE, LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN/MAX, data red-fastest). -pub(crate) fn parse_cube(text: &str) -> Option { +pub fn parse_cube(text: &str) -> Option { let mut lut = Lut { n1: 0, d1: vec![], n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }; let mut rows: Vec<[f32; 3]> = Vec::new(); let three = |it: &mut std::str::SplitWhitespace| -> Option<[f32; 3]> { @@ -238,6 +238,57 @@ impl Lut { } c } + /// Apply with a choice of 3D interpolation: 0 nearest, 1 trilinear, otherwise tetrahedral. + pub fn apply_interp(&self, c: [f32; 3], interp: u32) -> [f32; 3] { + let mut c = c; + if self.n1 > 1 { + let t = self.norm(c); + c = [0, 1, 2].map(|i| { + let x = t[i] * (self.n1 - 1) as f32; + if interp == 0 { + return self.d1[(x.round() as usize).min(self.n1 - 1)][i]; + } + let i0 = (x.floor() as usize).min(self.n1 - 2); + let f = x - i0 as f32; + self.d1[i0][i] + (self.d1[i0 + 1][i] - self.d1[i0][i]) * f + }); + } + if self.n3 > 1 { + let t = self.norm(c); + c = match interp { + 0 => self.nearest(t), + 1 => self.trilinear(t), + _ => self.tetra(t), + }; + } + c + } + fn nearest(&self, t: [f32; 3]) -> [f32; 3] { + let n = self.n3; + let s = (n - 1) as f32; + let i = |x: f32| ((x * s).round() as usize).min(n - 1); + self.d3[i(t[0]) + i(t[1]) * n + i(t[2]) * n * n] + } + /// Trilinear interpolation on the lattice (red fastest). + pub fn trilinear(&self, t: [f32; 3]) -> [f32; 3] { + let n = self.n3; + let s = (n - 1) as f32; + let idx = |x: f32| { + let v = x * s; + let i = (v.floor() as usize).min(n - 2); + (i, v - i as f32) + }; + let (r, fr) = idx(t[0]); + let (g, fg) = idx(t[1]); + let (b, fb) = idx(t[2]); + let at = |dr: usize, dg: usize, db: usize| self.d3[(r + dr) + (g + dg) * n + (b + db) * n * n]; + let l = |a: [f32; 3], b: [f32; 3], f: f32| [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]; + let c00 = l(at(0, 0, 0), at(1, 0, 0), fr); + let c10 = l(at(0, 1, 0), at(1, 1, 0), fr); + let c01 = l(at(0, 0, 1), at(1, 0, 1), fr); + let c11 = l(at(0, 1, 1), at(1, 1, 1), fr); + l(l(c00, c10, fg), l(c01, c11, fg), fb) + } fn tetra(&self, t: [f32; 3]) -> [f32; 3] { let n = self.n3; let s = (n - 1) as f32; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 14a81575..bd42d31a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -527,8 +527,15 @@ impl<'a> Renderer<'a> { let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let mask_shapes = masks::shapes(ctx, layer); let host = FxHost { r: self, ctx, layer, index: Default::default() }; - let env = - EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 }; + let env = EffectEnv { + masks: &mask_shapes, + host: Some(&host), + comp_time: ctx.time.seconds(), + frame_rate: ctx.comp.frame_rate.as_f64(), + effect_index: 0, + working_space: self.pipe.space, + working_linear: self.pipe.linear, + }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx .groups() From d9fe03a9653ba78301960611fff35150887969f3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 20:54:26 -0400 Subject: [PATCH 03/10] M9.11: Immersive Video effects (all 12 VR effects) Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), per-eye stereo layouts, VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 3 + crates/effects/src/vr.rs | 1188 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1191 insertions(+) create mode 100644 crates/effects/src/vr.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4bb658c3..14a43464 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -43,6 +43,7 @@ mod transition2; pub mod util; mod utility; mod utility2; +mod vr; use std::collections::HashMap; use std::sync::OnceLock; @@ -354,6 +355,7 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(color3::specs()); v.extend(obsolete::specs()); v.extend(ocio::specs()); + v.extend(vr::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); @@ -469,6 +471,7 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", + "ec.vr.digitalglitch", ]; /// See [`TIME_DEPENDENT`]. diff --git a/crates/effects/src/vr.rs b/crates/effects/src/vr.rs new file mode 100644 index 00000000..ba7e6d9d --- /dev/null +++ b/crates/effects/src/vr.rs @@ -0,0 +1,1188 @@ +//! Immersive Video effects (VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, +//! VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate +//! Sphere, VR Sharpen, VR Sphere To Plane). +//! +//! All of them treat the layer as a 360° equirectangular frame (longitude across, latitude down; +//! the front, +Z, at the centre) and are **seam-aware**: filters wrap around the left/right edge, +//! continue across the poles (a column continues on the opposite meridian), and spatial filters +//! widen their horizontal footprint by 1/cos(latitude) so a blur is the same size on the sphere +//! everywhere. Stereo footage (Frame Layout: over/under or side by side) is processed per eye. +//! +//! VR Converter maps between equirectangular, cube maps (4:3 cross, 3:2 and 6:1 strips), an +//! angular fisheye ("sphere map") and a rectilinear 2D view, through a common view direction. + +use std::f64::consts::PI; + +use effectcraft_color::{BlendMode, blend_pixel, luminance}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::util::{hash1, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +type V3 = [f64; 3]; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Immersive Video", params, render, gpu: false, float: true } +} + +pub const FRAME_LAYOUTS: [&str; 3] = ["Monoscopic", "Stereo - Over/Under", "Stereo - Side by Side"]; + +fn layout_param() -> crate::ParamSpec { + p("frameLayout", "Frame Layout", Value::Enum(0), popup(&FRAME_LAYOUTS)) +} + +/// Eye rectangles (x, y, w, h) of a frame. +fn eyes(w: u32, h: u32, layout: u32) -> Vec<(u32, u32, u32, u32)> { + match layout { + 1 if h >= 2 => vec![(0, 0, w, h / 2), (0, h / 2, w, h - h / 2)], + 2 if w >= 2 => vec![(0, 0, w / 2, h), (w / 2, 0, w - w / 2, h)], + _ => vec![(0, 0, w, h)], + } +} + +/// Run `f` on every eye of `img` and reassemble. +fn per_eye(img: &Image, layout: u32, f: impl Fn(&Image, usize) -> Image + Sync) -> Image { + let rects = eyes(img.width, img.height, layout); + if rects.len() == 1 { + return f(img, 0); + } + let outs: Vec = rects.par_iter().enumerate().map(|(i, &(x, y, w, h))| f(&img.crop(x as i64, y as i64, w, h), i)).collect(); + let mut out = Image::new(img.width, img.height); + for (&(x0, y0, w, _), o) in rects.iter().zip(&outs) { + for (yy, row) in o.data.chunks(w.max(1) as usize).enumerate() { + let start = out.idx(x0, y0 + yy as u32); + out.data[start..start + row.len()].copy_from_slice(row); + } + } + out +} + +// ---------------------------------------------------------------- sphere geometry + +#[inline] +fn norm(v: V3) -> V3 { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-300); + [v[0] / l, v[1] / l, v[2] / l] +} + +#[inline] +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Direction (y up, +z front) of an equirectangular position in pixels. +#[inline] +pub fn equi_dir(x: f64, y: f64, w: f64, h: f64) -> V3 { + let lon = (x / w - 0.5) * 2.0 * PI; + let lat = (0.5 - y / h) * PI; + [lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos()] +} + +/// Equirectangular pixel position of a direction. +#[inline] +pub fn equi_px(d: V3, w: f64, h: f64) -> (f64, f64) { + let lon = d[0].atan2(d[2]); + let lat = d[1].clamp(-1.0, 1.0).asin(); + ((lon / (2.0 * PI) + 0.5) * w, (0.5 - lat / PI) * h) +} + +/// Bilinear sample of an equirectangular image: wraps horizontally, continues over the poles. +pub fn sample_equi(img: &Image, x: f64, y: f64) -> Px { + let (w, h) = (img.width as i64, img.height as i64); + if w == 0 || h == 0 { + return [0.0; 4]; + } + let fx = x - 0.5; + let fy = y - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + let (tx, ty) = ((fx - x0) as f32, (fy - y0) as f32); + let at = |xi: i64, yi: i64| -> Px { + let (mut xi, mut yi) = (xi, yi); + if yi < 0 { + yi = -1 - yi; + xi += w / 2; + } else if yi >= h { + yi = 2 * h - 1 - yi; + xi += w / 2; + } + img.data[(yi.clamp(0, h - 1) * w + xi.rem_euclid(w)) as usize] + }; + let (x0, y0) = (x0 as i64, y0 as i64); + let (a, b, c, d) = (at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)); + let mut o = [0.0f32; 4]; + for k in 0..4 { + let top = a[k] + (b[k] - a[k]) * tx; + let bot = c[k] + (d[k] - c[k]) * tx; + o[k] = top + (bot - top) * ty; + } + o +} + +/// Rotation matrix (row-major) for tilt (about X), pan (about Y) and roll (about Z), degrees: +/// R = Ry(pan) · Rx(tilt) · Rz(roll). +pub fn rotation(tilt: f64, pan: f64, roll: f64) -> [[f64; 3]; 3] { + let (a, b, c) = (tilt.to_radians(), pan.to_radians(), roll.to_radians()); + let rx = [[1.0, 0.0, 0.0], [0.0, a.cos(), -a.sin()], [0.0, a.sin(), a.cos()]]; + let ry = [[b.cos(), 0.0, b.sin()], [0.0, 1.0, 0.0], [-b.sin(), 0.0, b.cos()]]; + let rz = [[c.cos(), -c.sin(), 0.0], [c.sin(), c.cos(), 0.0], [0.0, 0.0, 1.0]]; + mm(&ry, &mm(&rx, &rz)) +} + +fn mm(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o +} + +#[inline] +fn mv(m: &[[f64; 3]; 3], v: V3) -> V3 { + [dot(m[0], v), dot(m[1], v), dot(m[2], v)] +} + +#[inline] +fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 { + [m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2]] +} + +// ---------------------------------------------------------------- projections (VR Converter) + +pub const PROJECTIONS: [&str; 6] = ["Equirectangular", "Cube-map 4:3", "Cube-map 3:2", "Cube-map 6:1", "Sphere Map", "2D Rectilinear"]; + +/// Cube faces: (forward, right, up). +const FACES: [(V3, V3, V3); 6] = [ + ([1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), // +X right + ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), // -X left + ([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), // +Y top + ([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), // -Y bottom + ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // +Z front + ([0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), // -Z back +]; + +/// Grid size and the (column, row) of each face (+X, −X, +Y, −Y, +Z, −Z) for a cube layout. +fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) { + match kind { + // Horizontal cross: top over the front; left, front, right, back across the middle. + 1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]), + // 3 × 2: right, left, top / bottom, front, back. + 2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]), + // 6 × 1 strip in face order. + _ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]), + } +} + +/// A projection: frame position (pixels) ↔ view direction. +#[derive(Clone, Copy, Debug)] +pub struct Proj { + pub kind: u32, + /// Horizontal field of view in degrees (sphere map and 2D only). + pub fov: f64, +} + +impl Proj { + /// Direction seen at frame position (x, y) of a `w`×`h` frame (None outside the image). + pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option { + match self.kind { + 0 => Some(equi_dir(x, y, w, h)), + 1..=3 => { + let ((gw, gh), cells) = cube_grid(self.kind); + let (cw, ch) = (w / gw as f64, h / gh as f64); + let (cx, cy) = ((x / cw).floor(), (y / ch).floor()); + let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?; + let a = (x - cx * cw) / cw * 2.0 - 1.0; + let b = (y - cy * ch) / ch * 2.0 - 1.0; + let (fw, r, u) = FACES[f]; + Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]])) + } + 4 => { + // Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width. + let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); + let s = w.min(h) / 2.0; + let (u, v) = ((x - w / 2.0) / s, (h / 2.0 - y) / s); + let r = (u * u + v * v).sqrt(); + if r > 1.0 { + return None; + } + let th = r * half; + let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) }; + Some([th.sin() * cu, th.sin() * cv, th.cos()]) + } + _ => { + let t = (self.fov.clamp(1.0, 179.0) / 2.0).to_radians().tan(); + let u = (x / w * 2.0 - 1.0) * t; + let v = (1.0 - y / h * 2.0) * t * h / w; + Some(norm([u, v, 1.0])) + } + } + } + + /// Frame position (pixels) of direction `d`, and for cube maps the face rectangle to clamp + /// samples into. None when the direction is not visible. + pub fn pos(&self, d: V3, w: f64, h: f64) -> Option<(f64, f64, Option<[f64; 4]>)> { + match self.kind { + 0 => { + let (x, y) = equi_px(d, w, h); + Some((x, y, None)) + } + 1..=3 => { + let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?; + let (fw, r, u) = FACES[f]; + let k = dot(d, fw); + let (a, b) = (dot(d, r) / k, -dot(d, u) / k); + let ((gw, gh), cells) = cube_grid(self.kind); + let (cw, ch) = (w / gw as f64, h / gh as f64); + let (c, rr) = cells[f]; + let (x0, y0) = (c as f64 * cw, rr as f64 * ch); + Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch]))) + } + 4 => { + let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); + let th = d[2].clamp(-1.0, 1.0).acos(); + if th > half + 1e-9 { + return None; + } + let s = w.min(h) / 2.0; + let r = th / half; + let l = (d[0] * d[0] + d[1] * d[1]).sqrt(); + let (cu, cv) = if l > 1e-12 { (d[0] / l, d[1] / l) } else { (0.0, 0.0) }; + Some((w / 2.0 + cu * r * s, h / 2.0 - cv * r * s, None)) + } + _ => { + if d[2] <= 1e-9 { + return None; + } + let t = (self.fov.clamp(1.0, 179.0) / 2.0).to_radians().tan(); + let u = d[0] / d[2] / t; + let v = d[1] / d[2] / (t * h / w); + if u.abs() > 1.0 || v.abs() > 1.0 { + return None; + } + Some(((u + 1.0) * 0.5 * w, (1.0 - v) * 0.5 * h, None)) + } + } + } + + /// Sample image `img` (in this projection) in direction `d`. + pub fn sample(&self, img: &Image, d: V3) -> Px { + let (w, h) = (img.width as f64, img.height as f64); + match self.pos(d, w, h) { + None => [0.0; 4], + Some((x, y, None)) => { + if self.kind == 0 { + sample_equi(img, x, y) + } else { + img.sample_bilinear_clamped(x, y) + } + } + Some((x, y, Some(r))) => { + // Stay inside the face so neighbouring faces don't bleed in. + let x = x.clamp(r[0] + 0.5, r[2] - 0.5); + let y = y.clamp(r[1] + 0.5, r[3] - 0.5); + img.sample_bilinear_clamped(x, y) + } + } + } +} + +/// Re-project `src` (in `from`) into a `w`×`h` frame in `to`, rotating the view by `rot` +/// (output direction → source direction = rotᵀ · d). +pub fn convert(src: &Image, from: Proj, to: Proj, w: u32, h: u32, rot: Option<&[[f64; 3]; 3]>) -> Image { + let mut out = Image::new(w, h); + let (fw, fh) = (w as f64, h as f64); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = match to.dir(x as f64 + 0.5, y as f64 + 0.5, fw, fh) { + Some(d) => { + let d = match rot { + Some(m) => mtv(m, d), + None => d, + }; + from.sample(src, d) + } + None => [0.0; 4], + }; + } + }); + out +} + +// ---------------------------------------------------------------- seam-aware filters + +/// Horizontal box mean with wrap-around, radius `r` (pixels, ≤ w/2) on one row. +fn box_row_wrap(src: &[Px], dst: &mut [Px], r: usize) { + let n = src.len(); + if n == 0 { + return; + } + if r == 0 { + dst.copy_from_slice(src); + return; + } + let r = r.min((n - 1) / 2); + if r == 0 { + dst.copy_from_slice(src); + return; + } + let k = (2 * r + 1) as f64; + let mut acc = [0.0f64; 4]; + for i in 0..=2 * r { + let p = src[(i + n - r) % n]; + for c in 0..4 { + acc[c] += p[c] as f64; + } + } + for x in 0..n { + for c in 0..4 { + dst[x][c] = (acc[c] / k) as f32; + } + let add = src[(x + r + 1) % n]; + let sub = src[(x + n - r) % n]; + for c in 0..4 { + acc[c] += add[c] as f64 - sub[c] as f64; + } + } +} + +/// Vertical box mean with pole continuation, radius `r`, over all columns. +fn box_cols_pole(img: &Image, r: usize) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + if r == 0 || h == 0 { + return img.clone(); + } + let at = |x: usize, y: i64| -> Px { + let (mut x, mut y) = (x, y); + if y < 0 { + y = -1 - y; + x = (x + w / 2) % w; + } else if y >= h as i64 { + y = 2 * h as i64 - 1 - y; + x = (x + w / 2) % w; + } + img.data[y.clamp(0, h as i64 - 1) as usize * w + x] + }; + let k = (2 * r + 1) as f64; + let cols: Vec> = (0..w) + .into_par_iter() + .map(|x| { + let mut out = vec![[0.0f32; 4]; h]; + let mut acc = [0.0f64; 4]; + for j in -(r as i64)..=(r as i64) { + let p = at(x, j); + for c in 0..4 { + acc[c] += p[c] as f64; + } + } + for y in 0..h { + for c in 0..4 { + out[y][c] = (acc[c] / k) as f32; + } + let add = at(x, y as i64 + r as i64 + 1); + let sub = at(x, y as i64 - r as i64); + for c in 0..4 { + acc[c] += add[c] as f64 - sub[c] as f64; + } + } + out + }) + .collect(); + let mut out = Image::new(img.width, img.height); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = cols[x][y]; + } + }); + out +} + +/// Gaussian-like blur of an equirectangular image with standard deviation `sigma` pixels at +/// the equator (3 box passes per axis), wrapping at the seam and continuing over the poles. +pub fn sphere_blur(img: &Image, sigma: f64) -> Image { + if sigma < 0.1 || img.is_empty() { + return img.clone(); + } + let (w, h) = (img.width as usize, img.height as usize); + let radii = crate::util::box_radii(sigma, 3); + let mut cur = img.clone(); + let mut tmp = Image::new(img.width, img.height); + for &r in &radii { + tmp.rows_mut().for_each(|(y, row)| { + let lat = (0.5 - (y as f64 + 0.5) / h as f64) * PI; + let rr = ((r as f64) / lat.cos().max(1e-3)).round().min((w / 2) as f64) as usize; + box_row_wrap(&cur.data[y * w..(y + 1) * w], row, rr); + }); + std::mem::swap(&mut cur, &mut tmp); + } + for &r in &radii { + cur = box_cols_pole(&cur, r); + } + cur +} + +// ---------------------------------------------------------------- effects + +fn layout(ctx: &EffectCtx) -> u32 { + ctx.params.e("frameLayout") +} + +fn vr_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ctx.params.f("blurriness").max(0.0) * b.scale * 0.5; + if s < 0.1 { + return b; + } + b.img = per_eye(&b.img, layout(ctx), |e, _| sphere_blur(e, s)); + b +} + +fn vr_sharpen(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = ctx.params.f("sharpenAmount") as f32 / 100.0; + if amt == 0.0 { + return b; + } + let s = (1.5 * b.scale).max(0.5); + b.img = per_eye(&b.img, layout(ctx), |e, _| { + let bl = sphere_blur(e, s); + let mut o = e.clone(); + o.data.par_iter_mut().zip(bl.data.par_iter()).for_each(|(p, q)| { + for c in 0..3 { + p[c] = (p[c] + (p[c] - q[c]) * amt).clamp(0.0, p[3].max(0.0) * 4.0); + } + }); + o + }); + b +} + +fn vr_denoise(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = ctx.params.f("noiseLevel").max(0.0) / 100.0; + if level <= 0.0 { + return b; + } + let r = ((1.0 + level * 4.0) * b.scale).max(1.0) as usize; + let eps = (level * level * 0.02) as f32; + let detail = (ctx.params.f("detail") / 100.0).clamp(0.0, 1.0) as f32; + b.img = per_eye(&b.img, layout(ctx), |e, _| { + // Guided filter (self-guided, per channel) on a frame padded with wrapped / pole-continued + // pixels so the seam and poles are filtered like any other place. + let pad = r * 2 + 1; + let padded = wrap_pad(e, pad); + let guide = crate::util::Plane::luma(&padded); + let ch = crate::util::split(&padded); + let f: Vec = ch.par_iter().map(|c| crate::util::guided_filter(&guide, c, r, eps.max(1e-6))).collect(); + let joined = crate::util::join(&[f[0].clone(), f[1].clone(), f[2].clone(), f[3].clone()]); + let mut o = joined.crop(pad as i64, pad as i64, e.width, e.height); + o.data.par_iter_mut().zip(e.data.par_iter()).for_each(|(p, s)| { + for c in 0..4 { + p[c] = p[c] + (s[c] - p[c]) * detail; + } + p[3] = p[3].clamp(0.0, 1.0); + for c in 0..3 { + p[c] = p[c].max(0.0); + } + }); + o + }); + b +} + +/// Pad an equirectangular image by `pad` pixels: wrapped columns, pole-continued rows. +pub fn wrap_pad(img: &Image, pad: usize) -> Image { + let (w, h) = (img.width as i64, img.height as i64); + let p = pad as i64; + let mut out = Image::new((w + 2 * p) as u32, (h + 2 * p) as u32); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (mut sx, mut sy) = (x as i64 - p, y as i64 - p); + if sy < 0 { + sy = -1 - sy; + sx += w / 2; + } else if sy >= h { + sy = 2 * h - 1 - sy; + sx += w / 2; + } + *px = img.data[(sy.clamp(0, h - 1) * w + sx.rem_euclid(w.max(1))) as usize]; + } + }); + out +} + +fn vr_glow(ctx: &EffectCtx, mut b: Buf) -> Buf { + let th = ctx.params.f("luminanceThreshold") as f32 / 100.0; + let radius = ctx.params.f("glowRadius").max(0.0) * b.scale; + let bright = ctx.params.f("glowBrightness") as f32 / 100.0; + let sat = ctx.params.f("glowSaturation") as f32 / 100.0; + let tint = ctx.params.b("useTintColor"); + let tc = ctx.params.color("tintColor"); + if bright <= 0.0 { + return b; + } + b.img = per_eye(&b.img, layout(ctx), |e, _| { + let mut hi = e.clone(); + hi.data.par_iter_mut().for_each(|p| { + let (c, a) = unpremul(*p); + let l = luminance(c[0], c[1], c[2]); + let k = if l > th { ((l - th) / (1.0 - th).max(1e-3)).min(4.0) } else { 0.0 }; + let mut c = c.map(|v| v * k); + let lc = luminance(c[0], c[1], c[2]); + c = c.map(|v| lc + (v - lc) * sat); + if tint { + c = [lc * tc[0], lc * tc[1], lc * tc[2]]; + } + *p = [c[0] * a, c[1] * a, c[2] * a, a * k.min(1.0)]; + }); + let g = sphere_blur(&hi, (radius * 0.5).max(0.5)); + let mut o = e.clone(); + o.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(p, q)| { + for c in 0..3 { + p[c] += q[c] * bright; + } + p[3] = (p[3] + q[3] * bright).min(1.0); + }); + o + }); + b +} + +fn rot_params(ctx: &EffectCtx) -> [[f64; 3]; 3] { + rotation(ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll")) +} + +fn vr_rotate(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll")); + if t == 0.0 && pn == 0.0 && r == 0.0 { + return b; + } + let m = rot_params(ctx); + let eq = Proj { kind: 0, fov: 360.0 }; + b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, eq, eq, e.width, e.height, Some(&m))); + b +} + +fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") }; + let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") }; + let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll")); + if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 { + return b; + } + let m = rot_params(ctx); + b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, from, to, e.width, e.height, Some(&m))); + b +} + +fn vr_sphere_to_plane(ctx: &EffectCtx, mut b: Buf) -> Buf { + let fov = ctx.params.f("horizontalFov"); + let m = rot_params(ctx); + let eq = Proj { kind: 0, fov: 360.0 }; + let flat = Proj { kind: 5, fov }; + b.img = per_eye(&b.img, layout(ctx), |e, _| convert(e, eq, flat, e.width, e.height, Some(&m))); + b +} + +fn vr_plane_to_sphere(ctx: &EffectCtx, mut b: Buf) -> Buf { + // The layer is a flat picture placed on the sphere: horizontal FOV = 90° × scale. + let fov = (90.0 * ctx.params.f("scale") / 100.0).clamp(1.0, 179.0); + let m = rot_params(ctx); + let feather = (ctx.params.f("feather") / 100.0).clamp(0.0, 1.0); + let flat = Proj { kind: 5, fov }; + let eq = Proj { kind: 0, fov: 360.0 }; + let src = b.img.clone(); + let mut out = convert(&src, flat, eq, src.width, src.height, Some(&m)); + if feather > 0.0 { + let (w, h) = (src.width as f64, src.height as f64); + let t = (fov / 2.0).to_radians().tan(); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = mtv(&m, equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h)); + if d[2] <= 0.0 { + continue; + } + let u = (d[0] / d[2] / t).abs(); + let v = (d[1] / d[2] / (t * h / w)).abs(); + let edge = 1.0 - u.max(v); + let k = (edge / feather).clamp(0.0, 1.0) as f32; + for c in px.iter_mut() { + *c *= k; + } + } + }); + } + b.img = out; + b +} + +/// Angle (radians) between two directions. +#[inline] +fn angle(a: V3, b: V3) -> f64 { + dot(a, b).clamp(-1.0, 1.0).acos() +} + +fn vr_chromatic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = [ctx.params.f("aberrationRed"), ctx.params.f("aberrationGreen"), ctx.params.f("aberrationBlue")].map(|v| v / 100.0 * 0.1); + if k.iter().all(|v| *v == 0.0) { + return b; + } + let center = mv(&rotation(ctx.params.f("centerTilt"), ctx.params.f("centerPan"), 0.0), [0.0, 0.0, 1.0]); + let falloff = (ctx.params.f("falloff") / 100.0).clamp(0.0, 1.0); + b.img = per_eye(&b.img, layout(ctx), |e, _| { + let (w, h) = (e.width as f64, e.height as f64); + let mut o = Image::new(e.width, e.height); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h); + let th = angle(d, center); + // Axis perpendicular to centre and d: rotate d about it toward/away from centre. + let ax = [center[1] * d[2] - center[2] * d[1], center[2] * d[0] - center[0] * d[2], center[0] * d[1] - center[1] * d[0]]; + let al = dot(ax, ax).sqrt(); + let weight = (th / PI).powf(1.0 + falloff * 3.0); + let mut out = [0.0f32; 4]; + for c in 0..3 { + let s = if al < 1e-9 { + e.data[(y * e.width as usize) + x] + } else { + let new_th = th * (1.0 - k[c] * weight); + // Direction at angle new_th from the centre in the plane of (centre, d). + let perp = norm([d[0] - center[0] * th.cos(), d[1] - center[1] * th.cos(), d[2] - center[2] * th.cos()]); + let nd = [ + center[0] * new_th.cos() + perp[0] * new_th.sin(), + center[1] * new_th.cos() + perp[1] * new_th.sin(), + center[2] * new_th.cos() + perp[2] * new_th.sin(), + ]; + let (sx, sy) = equi_px(nd, w, h); + sample_equi(e, sx, sy) + }; + out[c] = s[c]; + out[3] = out[3].max(s[3]); + } + *px = out; + } + }); + o + }); + b +} + +const BLEND_OPTS: [&str; 6] = ["None", "Normal", "Add", "Multiply", "Screen", "Overlay"]; + +fn blend_mode(i: u32) -> Option { + match i { + 0 => None, + 2 => Some(BlendMode::Add), + 3 => Some(BlendMode::Multiply), + 4 => Some(BlendMode::Screen), + 5 => Some(BlendMode::Overlay), + _ => Some(BlendMode::Normal), + } +} + +/// Composite a generated eye image over the original with a blend mode and opacity (mode None +/// replaces the layer). +fn composite(orig: &Image, layer: &Image, mode: u32, opacity: f32) -> Image { + let mut o = orig.clone(); + o.data.par_iter_mut().zip(layer.data.par_iter()).for_each(|(d, s)| { + let s = [s[0] * opacity, s[1] * opacity, s[2] * opacity, s[3] * opacity]; + *d = match blend_mode(mode) { + None => s, + Some(m) => { + let mut r = blend_pixel(m, *d, s, 0.5); + // Keep the layer's own coverage: generated pixels show only where it is opaque. + let a = d[3]; + if a < 1.0 { + let k = a / r[3].max(1e-6); + if k < 1.0 { + r = r.map(|v| v * k); + } + } + r + } + }; + }); + o +} + +fn vr_gradients(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let blend = pr.f("blend").max(0.1); + let opacity = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let pts: Vec<(V3, [f32; 4])> = (1..=5) + .filter(|i| pr.b(&format!("enablePoint{i}"))) + .map(|i| { + let p = pr.v2(&format!("point{i}")); + (equi_dir(p[0], p[1], lw, lh), pr.color(&format!("color{i}"))) + }) + .collect(); + if pts.is_empty() { + return b; + } + b.img = per_eye(&b.img, layout(ctx), |e, _| { + let (w, h) = (e.width as f64, e.height as f64); + let mut g = Image::new(e.width, e.height); + g.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h); + // Inverse-distance weighting on great-circle distance. + let mut acc = [0.0f64; 3]; + let mut ws = 0.0; + let mut exact = None; + for (pd, c) in &pts { + let a = angle(d, *pd); + if a < 1e-6 { + exact = Some(*c); + break; + } + let wgt = 1.0 / a.powf(blend); + for k in 0..3 { + acc[k] += c[k] as f64 * wgt; + } + ws += wgt; + } + let c = exact.map(|c| [c[0], c[1], c[2]]).unwrap_or([(acc[0] / ws) as f32, (acc[1] / ws) as f32, (acc[2] / ws) as f32]); + *px = [c[0], c[1], c[2], 1.0]; + } + }); + composite(e, &g, mode, opacity) + }); + b +} + +fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let kind = pr.e("fractalType"); + let invert = pr.b("invert"); + let contrast = pr.f("contrast") as f32 / 100.0; + let brightness = pr.f("brightness") as f32 / 100.0; + let scale = (pr.f("scale") / 100.0).max(0.01) as f32; + let octaves = pr.f("complexity").clamp(1.0, 20.0); + let evo = (pr.f("evolution") / 360.0) as f32; + let opacity = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + let seed = pr.f("randomSeed") as u32; + let base = 4.0 / scale; + b.img = per_eye(&b.img, layout(ctx), |e, _| { + let (w, h) = (e.width as f64, e.height as f64); + let mut g = Image::new(e.width, e.height); + g.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w, h); + let mut v = 0.0f32; + let mut amp = 1.0f32; + let mut freq = base; + let mut norm = 0.0f32; + let n_oct = octaves.ceil() as usize; + for o in 0..n_oct { + let wgt = if o + 1 == n_oct && octaves.fract() > 0.0 { octaves.fract() as f32 } else { 1.0 }; + let q = [d[0] as f32 * freq + evo * 0.37, d[1] as f32 * freq + evo * 0.71, d[2] as f32 * freq + evo * 0.59]; + let n = value_noise(q[0] + o as f32 * 17.3, q[1], q[2], seed.wrapping_add(o as u32)); + let n = match kind { + 1 => (n * 2.0 - 1.0).abs(), + 2 => 1.0 - (n * 2.0 - 1.0).abs(), + _ => n, + }; + v += n * amp * wgt; + norm += amp * wgt; + amp *= 0.5; + freq *= 2.0; + } + let mut v = v / norm.max(1e-6); + v = (v - 0.5) * contrast + 0.5 + brightness; + if invert { + v = 1.0 - v; + } + let v = v.clamp(0.0, 1.0); + *px = [v, v, v, 1.0]; + } + }); + composite(e, &g, mode, opacity) + }); + b +} + +fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let amp = (pr.f("masterAmplitude") / 100.0).max(0.0); + if amp <= 0.0 { + return b; + } + let rate = pr.f("distortionRate").max(0.0); + let geo = pr.f("geometricDistortion") / 100.0 * amp; + let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0); + let rgb = pr.f("colorDistortion") / 100.0 * amp; + let scan = (pr.f("scanlines") / 100.0 * amp) as f32; + let seed = (pr.f("randomSeed") as u32) ^ ctx.seed; + // The glitch pattern changes `rate` times a second (held between changes). + let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32; + b.img = per_eye(&b.img, layout(ctx), |e, eye| { + let (w, h) = (e.width as f64, e.height as f64); + let bands = complexity.round() as u32; + let mut o = Image::new(e.width, e.height); + o.rows_mut().for_each(|(y, row)| { + let band = ((y as f64 / h) * bands as f64) as u32; + let r0 = hash1(band, slot, seed ^ (eye as u32 * 977)); + let on = r0 < 0.35; + let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 }; + let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 }; + let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 }; + for (x, px) in row.iter_mut().enumerate() { + let sx = x as f64 + 0.5 - shift; + let sy = y as f64 + 0.5; + // Horizontal shifts wrap around the seam (the frame is a full turn). + let g = sample_equi(e, sx, sy); + let r = sample_equi(e, sx - split, sy); + let bb = sample_equi(e, sx + split, sy); + *px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])]; + } + }); + o + }); + b +} + +fn ang(id: &'static str, name: &'static str) -> crate::ParamSpec { + p(id, name, num(0.0), ParamUi::Angle) +} + +pub fn specs() -> Vec { + let pct = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)); + let mut grad = vec![layout_param()]; + let defaults = [ + ([0.25, 0.5], [1.0, 0.0, 0.0]), + ([0.5, 0.25], [1.0, 1.0, 0.0]), + ([0.75, 0.5], [0.0, 0.0, 1.0]), + ([0.5, 0.75], [0.0, 1.0, 0.0]), + ([0.0, 0.5], [1.0, 0.0, 1.0]), + ]; + const IDS: [[&str; 6]; 5] = [ + ["enablePoint1", "Enable Point 1", "point1", "Point 1", "color1", "Color 1"], + ["enablePoint2", "Enable Point 2", "point2", "Point 2", "color2", "Color 2"], + ["enablePoint3", "Enable Point 3", "point3", "Point 3", "color3", "Color 3"], + ["enablePoint4", "Enable Point 4", "point4", "Point 4", "color4", "Color 4"], + ["enablePoint5", "Enable Point 5", "point5", "Point 5", "color5", "Color 5"], + ]; + for (i, (ids, (pt, c))) in IDS.iter().zip(defaults).enumerate() { + grad.push(p(ids[0], ids[1], Value::Bool(i < 4), ParamUi::Checkbox)); + grad.push(p(ids[2], ids[3], Value::Vec2(pt), ParamUi::Point)); + grad.push(p(ids[4], ids[5], col(c[0], c[1], c[2]), ParamUi::Color)); + } + grad.push(p("blend", "Blend", num(2.0), slider(0.1, 10.0, 0.5, 6.0, 2))); + let (v, u) = pct(100.0); + grad.push(p("opacity", "Opacity", v, u)); + grad.push(p("blendingMode", "Blending Mode", Value::Enum(0), popup(&BLEND_OPTS))); + vec![ + spec("ec.vr.blur", "VR Blur", vec![layout_param(), p("blurriness", "Blurriness", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1))], vr_blur), + spec( + "ec.vr.chromaticaberrations", + "VR Chromatic Aberrations", + vec![ + layout_param(), + ang("centerTilt", "Center Tilt"), + ang("centerPan", "Center Pan"), + p("aberrationRed", "Aberration (Red)", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("aberrationGreen", "Aberration (Green)", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("aberrationBlue", "Aberration (Blue)", num(-10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("falloff", "Falloff", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + vr_chromatic, + ), + spec("ec.vr.colorgradients", "VR Color Gradients", grad, vr_gradients), + spec( + "ec.vr.converter", + "VR Converter", + vec![ + layout_param(), + p("sourceProjection", "Source Projection", Value::Enum(0), popup(&PROJECTIONS)), + p("sourceHorizontalFov", "Source Horizontal FOV", num(90.0), slider(1.0, 360.0, 1.0, 360.0, 1)), + p("targetProjection", "Target Projection", Value::Enum(2), popup(&PROJECTIONS)), + p("targetHorizontalFov", "Target Horizontal FOV", num(90.0), slider(1.0, 360.0, 1.0, 360.0, 1)), + ang("tilt", "Tilt (X Axis)"), + ang("pan", "Pan (Y Axis)"), + ang("roll", "Roll (Z Axis)"), + ], + vr_converter, + ), + spec( + "ec.vr.denoise", + "VR De-Noise", + vec![ + layout_param(), + p("noiseLevel", "Noise Level", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("detail", "Detail", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + vr_denoise, + ), + spec( + "ec.vr.digitalglitch", + "VR Digital Glitch", + vec![ + layout_param(), + p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)), + p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)), + p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), + ], + vr_glitch, + ), + spec( + "ec.vr.fractalnoise", + "VR Fractal Noise", + vec![ + layout_param(), + p("fractalType", "Fractal Type", Value::Enum(0), popup(&["Basic", "Turbulent Smooth", "Turbulent Sharp"])), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)), + p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)), + p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)), + p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 20.0, 1)), + ang("evolution", "Evolution"), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), + p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&BLEND_OPTS)), + ], + vr_fractal, + ), + spec( + "ec.vr.glow", + "VR Glow", + vec![ + layout_param(), + p("luminanceThreshold", "Luminance Threshold", num(60.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("glowRadius", "Glow Radius", num(20.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("glowBrightness", "Glow Brightness", num(100.0), slider(0.0, 1000.0, 0.0, 400.0, 1)), + p("glowSaturation", "Glow Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("useTintColor", "Use Tint Color", Value::Bool(false), ParamUi::Checkbox), + p("tintColor", "Tint Color", col(1.0, 0.8, 0.5), ParamUi::Color), + ], + vr_glow, + ), + spec( + "ec.vr.planetosphere", + "VR Plane to Sphere", + vec![ + layout_param(), + p("scale", "Scale", num(100.0), slider(1.0, 198.0, 1.0, 198.0, 1)), + ang("tilt", "Tilt (X Axis)"), + ang("pan", "Pan (Y Axis)"), + ang("roll", "Roll (Z Axis)"), + p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + vr_plane_to_sphere, + ), + spec( + "ec.vr.rotatesphere", + "VR Rotate Sphere", + vec![layout_param(), ang("tilt", "Tilt (X Axis)"), ang("pan", "Pan (Y Axis)"), ang("roll", "Roll (Z Axis)")], + vr_rotate, + ), + spec( + "ec.vr.sharpen", + "VR Sharpen", + vec![layout_param(), p("sharpenAmount", "Sharpen Amount", num(20.0), slider(0.0, 500.0, 0.0, 100.0, 1))], + vr_sharpen, + ), + spec( + "ec.vr.spheretoplane", + "VR Sphere To Plane", + vec![ + layout_param(), + p("horizontalFov", "Horizontal FOV", num(90.0), slider(1.0, 179.0, 10.0, 170.0, 1)), + ang("tilt", "Tilt (X Axis)"), + ang("pan", "Pan (Y Axis)"), + ang("roll", "Roll (Z Axis)"), + ], + vr_sphere_to_plane, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + /// A smooth function of the view direction rendered as an equirectangular image. + fn sphere_img(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let d = equi_dir(x as f64 + 0.5, y as f64 + 0.5, w as f64, h as f64); + img.set(x, y, [(0.5 + 0.4 * d[0]) as f32, (0.5 + 0.4 * d[1]) as f32, (0.5 + 0.4 * d[2]) as f32, 1.0]); + } + } + img + } + + #[test] + fn projections_are_inverse() { + for kind in 0..6u32 { + let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } }; + let (w, h) = (240.0, 120.0); + for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] { + let Some(d) = pr.dir(x, y, w, h) else { continue }; + let (px, py, _) = pr.pos(d, w, h).unwrap(); + assert!((px - x).abs() < 1e-6 && (py - y).abs() < 1e-6, "kind {kind}: ({x},{y}) → ({px},{py})"); + } + } + } + + #[test] + fn converter_round_trips_equirect_through_cube_maps() { + let img = sphere_img(128, 64); + for target in [1u32, 2, 3] { + let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default()); + let back = run_fx( + "ec.vr.converter", + &[("sourceProjection", Value::Enum(target)), ("targetProjection", Value::Enum(0))], + cube.img.clone(), + 0.0, + EffectEnv::default(), + ); + let mut worst = 0.0f32; + let mut sum = 0.0f32; + for (a, b) in back.img.data.iter().zip(&img.data) { + let e = (0..3).map(|k| (a[k] - b[k]).abs()).fold(0.0, f32::max); + worst = worst.max(e); + sum += e; + } + let mean = sum / img.data.len() as f32; + // The 6:1 strip squeezes each face into a narrow cell, so its seams are coarser. + let tol = if target == 3 { 0.25 } else { 0.08 }; + assert!(mean < 0.01 && worst < tol, "target {target}: mean {mean} worst {worst}"); + // Cube faces carry the expected directions: the front face centre looks at +Z. + let pr = Proj { kind: target, fov: 90.0 }; + let (x, y, _) = pr.pos([0.0, 0.0, 1.0], 128.0, 64.0).unwrap(); + let px = cube.img.sample_bilinear_clamped(x, y); + assert!((px[2] - 0.9).abs() < 0.02, "{px:?}"); + } + } + + #[test] + fn rotate_sphere_pans_and_round_trips() { + let img = sphere_img(96, 48); + let a = run_fx("ec.vr.rotatesphere", &[("pan", num(90.0))], img.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.vr.rotatesphere", &[("pan", num(-90.0))], a.img.clone(), 0.0, EffectEnv::default()); + let err: f32 = b.img.data.iter().zip(&img.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::() / img.data.len() as f32; + assert!(err < 0.01, "{err}"); + // A 90° pan is a horizontal shift by a quarter turn. + let (x, y) = (10u32, 24u32); + let shifted = img.get(((x + 24) % 96) as i64, y as i64); + let got = a.img.get(x as i64, y as i64); + let alt = img.get(((x + 72) % 96) as i64, y as i64); + assert!((got[0] - shifted[0]).abs() < 0.02 || (got[0] - alt[0]).abs() < 0.02); + } + + #[test] + fn blur_is_seamless() { + // A frame whose left and right edges differ: after blurring, the two edge columns agree + // (they are neighbours on the sphere). + let mut img = Image::new(64, 32); + for y in 0..32 { + for x in 0..64 { + let v = if x < 4 { + 1.0 + } else if x >= 60 { + 0.0 + } else { + 0.5 + }; + img.set(x, y, [v, v, v, 1.0]); + } + } + let out = run_fx("ec.vr.blur", &[("blurriness", num(8.0))], img.clone(), 0.0, EffectEnv::default()); + let (l, r) = (out.img.get(0, 16)[0], out.img.get(63, 16)[0]); + assert!((l - r).abs() < 0.2, "{l} {r}"); + assert!(r > 0.2 && l < 0.85); + // Pole rows are uniform (every pixel of the top row is the same point). + let top: Vec = (0..64).map(|x| out.img.get(x, 0)[0]).collect(); + let spread = top.iter().cloned().fold(f32::MIN, f32::max) - top.iter().cloned().fold(f32::MAX, f32::min); + assert!(spread < 0.1, "{spread}"); + // Constant images stay constant. + let flat = Image::filled(32, 16, [0.3, 0.3, 0.3, 1.0]); + let o = run_fx("ec.vr.blur", &[], flat, 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| (p[0] - 0.3).abs() < 1e-4)); + } + + #[test] + fn stereo_layouts_process_each_eye() { + let mut img = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + let v = if y < 16 { 0.2 } else { 0.8 }; + img.set(x, y, [v, v, v, 1.0]); + } + } + // Over/under: blurring each eye keeps the eyes separate. + let o = run_fx("ec.vr.blur", &[("frameLayout", Value::Enum(1)), ("blurriness", num(10.0))], img.clone(), 0.0, EffectEnv::default()); + assert!((o.img.get(5, 15)[0] - 0.2).abs() < 1e-4 && (o.img.get(5, 16)[0] - 0.8).abs() < 1e-4); + let mono = run_fx("ec.vr.blur", &[("blurriness", num(10.0))], img, 0.0, EffectEnv::default()); + assert!((mono.img.get(5, 15)[0] - 0.2).abs() > 0.01); + } + + #[test] + fn sphere_to_plane_and_back() { + let img = sphere_img(128, 64); + // The centre of a 2D view looking forward sees +Z (blue 0.9). + let v = run_fx("ec.vr.spheretoplane", &[], img.clone(), 0.0, EffectEnv::default()); + let c = v.img.get(64, 32); + assert!((c[2] - 0.9).abs() < 0.02, "{c:?}"); + // Looking up (tilt 90°): +Y (green 0.9). + let up = run_fx("ec.vr.spheretoplane", &[("tilt", num(-90.0))], img.clone(), 0.0, EffectEnv::default()); + let u = up.img.get(64, 32); + let dn = run_fx("ec.vr.spheretoplane", &[("tilt", num(90.0))], img, 0.0, EffectEnv::default()); + let d = dn.img.get(64, 32); + assert!((u[1] - 0.9).abs() < 0.03 || (d[1] - 0.9).abs() < 0.03, "{u:?} {d:?}"); + // Plane to sphere puts the picture in front, transparent behind. + let pic = Image::filled(64, 32, [1.0, 0.0, 0.0, 1.0]); + let s = run_fx("ec.vr.planetosphere", &[], pic, 0.0, EffectEnv::default()); + assert!(s.img.get(32, 16)[3] > 0.99 && s.img.get(1, 16)[3] < 0.01); + } + + #[test] + fn generators_and_filters_are_deterministic_and_seamless() { + let img = sphere_img(64, 32); + for (id, vals) in [ + ("ec.vr.fractalnoise", vec![]), + ("ec.vr.colorgradients", vec![]), + ("ec.vr.glow", vec![("luminanceThreshold", num(50.0))]), + ("ec.vr.sharpen", vec![]), + ("ec.vr.denoise", vec![]), + ("ec.vr.chromaticaberrations", vec![]), + ("ec.vr.digitalglitch", vec![]), + ] { + let a = run_fx(id, &vals, img.clone(), 0.5, EffectEnv::default()); + let b = run_fx(id, &vals, img.clone(), 0.5, EffectEnv::default()); + assert_eq!(a.img.data, b.img.data, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + } + // Fractal noise is continuous across the seam: the first and last columns are close. + let n = run_fx("ec.vr.fractalnoise", &[], img.clone(), 0.0, EffectEnv::default()); + let mut worst = 0.0f32; + for y in 0..32 { + worst = worst.max((n.img.get(0, y)[0] - n.img.get(63, y)[0]).abs()); + } + assert!(worst < 0.15, "{worst}"); + // Gradients reach the point colour at the point. + let pts = [ + ("point1", Value::Vec2([16.0, 16.0])), + ("point2", Value::Vec2([32.0, 8.0])), + ("point3", Value::Vec2([48.0, 16.0])), + ("point4", Value::Vec2([32.0, 24.0])), + ]; + let g = run_fx("ec.vr.colorgradients", &pts, img.clone(), 0.0, EffectEnv::default()); + let px = g.img.get(16, 16); + assert!(px[0] > 0.8 && px[1] < 0.3, "{px:?}"); + // Glitch changes over time. + let g0 = run_fx("ec.vr.digitalglitch", &[], img.clone(), 0.0, EffectEnv::default()); + let g1 = run_fx("ec.vr.digitalglitch", &[], img.clone(), 3.3, EffectEnv::default()); + assert_ne!(g0.img.data, g1.img.data); + } +} From 451f63cc0d706232be34db43f43b00c9695d79c7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:08:04 -0400 Subject: [PATCH 04/10] M9.11: Blur & Sharpen and Distort effects (CC Radial Blur, Camera-Shake Deblur, CC Flo Motion, Liquify, Rolling Shutter Repair) Liquify stores its strokes as effect data and replays them into a cached displacement mesh; liquify.stroke / liquify.clear commands for agents. Rolling Shutter Repair measures motion with effectcraft-track KLT (new effects -> track same-layer edge). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/effects/Cargo.toml | 1 + crates/effects/src/blur3.rs | 338 ++++++++++++ crates/effects/src/distort4.rs | 719 ++++++++++++++++++++++++++ crates/effects/src/lib.rs | 6 + crates/engine/src/commands/liquify.rs | 156 ++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/tests_paint.rs | 23 + docs/architecture.md | 2 +- xtask/src/main.rs | 1 + 10 files changed, 1248 insertions(+), 1 deletion(-) create mode 100644 crates/effects/src/blur3.rs create mode 100644 crates/effects/src/distort4.rs create mode 100644 crates/engine/src/commands/liquify.rs diff --git a/Cargo.lock b/Cargo.lock index 635fb0f5..46f34e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-project", "effectcraft-raster", + "effectcraft-track", "rayon", "serde_json", ] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index db74bf27..e321fa2f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -15,6 +15,7 @@ effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } +effectcraft-track = { workspace = true } rayon = { workspace = true } [dev-dependencies] diff --git a/crates/effects/src/blur3.rs b/crates/effects/src/blur3.rs new file mode 100644 index 00000000..0140ac99 --- /dev/null +++ b/crates/effects/src/blur3.rs @@ -0,0 +1,338 @@ +//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur. +//! +//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or +//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a +//! sample count set by Quality. +//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring +//! by "lucky frames": the frame is split into patches; for each patch the sharpness (mean +//! squared gradient) is compared with the same patch in the neighbouring frames within Blur +//! Duration; when a neighbour is clearly sharper, its patch is aligned to the current frame by +//! a small translation search (sum of squared differences on luma) and substituted with +//! feathered patch blending. Deterministic; neighbours come from [`crate::EffectHost::self_at`]. + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::Plane; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- CC Radial Blur + +pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"]; + +fn cc_radial_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount"); + if amount == 0.0 { + return b; + } + let kind = ctx.params.e("type"); + let quality = ctx.params.f("quality").clamp(1.0, 100.0); + let c = b.to_px(ctx.params.v2("center")); + let src = b.img.clone(); + // Zoom: fraction of the distance to the centre; rotate: degrees of arc. + let zoom = (amount / 100.0).clamp(-1.0, 1.0) * 0.5; + let arc = amount.to_radians() * 0.25; + b.img.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt(); + let span = if kind >= 3 { r * arc.abs() } else { r * zoom.abs() }; + let n = ((span * quality / 50.0).ceil() as usize).clamp(1, 256); + if n <= 1 { + *o = src.get(x as i64, y as i64); + continue; + } + let mut acc = [0.0f32; 4]; + let mut wsum = 0.0f32; + for i in 0..n { + let t = i as f64 / (n - 1) as f64; + let (sx, sy, w) = match kind { + // Toward the centre, equal weights. + 0 => { + let k = 1.0 - zoom * t; + (c.0 + dx * k, c.1 + dy * k, 1.0) + } + // Toward the centre, weights fading with distance along the streak. + 1 => { + let k = 1.0 - zoom * t; + (c.0 + dx * k, c.1 + dy * k, (1.0 - t) as f32 + 0.05) + } + // Both ways around the pixel. + 2 => { + let k = 1.0 + zoom * (t - 0.5); + (c.0 + dx * k, c.1 + dy * k, 1.0) + } + _ => { + let a = arc * (t - 0.5); + let (s, co) = a.sin_cos(); + let w = if kind == 4 { (1.0 - (2.0 * t - 1.0).abs()) as f32 + 0.05 } else { 1.0 }; + (c.0 + dx * co - dy * s, c.1 + dx * s + dy * co, w) + } + }; + let q = src.sample_bilinear(sx, sy); + for k in 0..4 { + acc[k] += q[k] * w; + } + wsum += w; + } + *o = acc.map(|v| v / wsum); + } + }); + b +} + +// ---------------------------------------------------------------- Camera-Shake Deblur + +/// Mean squared luma gradient over a rectangle (sharpness). +fn sharpness(l: &Plane, x0: usize, y0: usize, x1: usize, y1: usize) -> f64 { + let mut s = 0.0f64; + let mut n = 0usize; + for y in y0..y1.saturating_sub(1) { + for x in x0..x1.saturating_sub(1) { + let v = l.get(x, y); + let gx = l.get(x + 1, y) - v; + let gy = l.get(x, y + 1) - v; + s += (gx * gx + gy * gy) as f64; + n += 1; + } + } + if n == 0 { 0.0 } else { s / n as f64 } +} + +/// Best integer translation (dx, dy) within ±`r` aligning `b` to `a` on a patch (SSD). +fn align(a: &Plane, bp: &Plane, x0: usize, y0: usize, x1: usize, y1: usize, r: i64) -> (i64, i64) { + let mut best = (f64::INFINITY, 0i64, 0i64); + let step = ((x1 - x0).max(y1 - y0) / 24).max(1); + for dy in -r..=r { + for dx in -r..=r { + let mut s = 0.0f64; + for y in (y0..y1).step_by(step) { + for x in (x0..x1).step_by(step) { + let d = a.get(x, y) - bp.get_clamped(x as i64 + dx, y as i64 + dy); + s += (d * d) as f64; + } + } + // Prefer the smallest shift on ties. + let s = s * (1.0 + 1e-9 * (dx * dx + dy * dy) as f64); + if s < best.0 { + best = (s, dx, dy); + } + } + } + (best.1, best.2) +} + +fn camera_shake_deblur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(host) = ctx.env.host else { return b }; + let dur = ctx.params.f("blurDuration").clamp(1.0, 10.0).round() as i64; + let search = (ctx.params.f("searchRange").max(0.0) * b.scale).round() as i64; + let patch = (ctx.params.f("patchSize").max(8.0) * b.scale).round().max(4.0) as usize; + let thresh = 1.0 + ctx.params.f("threshold").max(0.0) / 100.0; + let show = ctx.params.b("showBlurryFrames"); + let fps = ctx.fps(); + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return b; + } + // Neighbouring frames in the effect's own input, resampled into this buffer's grid. + let mut neigh: Vec<(Image, Plane)> = vec![]; + for k in (-dur..=dur).filter(|k| *k != 0) { + let Some(nb) = host.self_at(ctx.time + k as f64 / fps, ctx.env.effect_index) else { continue }; + let img = if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { + nb.img + } else { + let k = nb.scale / b.scale.max(1e-9); + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; + let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; + nb.img.sample_bilinear(lx, ly) + }) + }; + let l = Plane::luma(&img); + neigh.push((img, l)); + } + if neigh.is_empty() { + return b; + } + let cur_l = Plane::luma(&b.img); + let (nx, ny) = (w.div_ceil(patch), h.div_ceil(patch)); + // Per patch: (neighbour index, shift) of the substitute, or None to keep. + let choice: Vec> = (0..nx * ny) + .into_par_iter() + .map(|i| { + let (px, py) = (i % nx, i / nx); + let (x0, y0) = (px * patch, py * patch); + let (x1, y1) = ((x0 + patch).min(w), (y0 + patch).min(h)); + let s0 = sharpness(&cur_l, x0, y0, x1, y1); + let mut best: Option<(f64, usize)> = None; + for (k, (_, l)) in neigh.iter().enumerate() { + let s = sharpness(l, x0, y0, x1, y1); + if s > s0 * thresh && best.is_none_or(|(bs, _)| s > bs) { + best = Some((s, k)); + } + } + let (_, k) = best?; + let (dx, dy) = align(&cur_l, &neigh[k].1, x0, y0, x1, y1, search); + Some((k, dx, dy)) + }) + .collect(); + if choice.iter().all(Option::is_none) { + return b; + } + let src = b.img.clone(); + // Feathered blending between patch decisions: each pixel mixes the four nearest patch + // centres bilinearly. + let pick = |i: usize, x: usize, y: usize| -> Px { + match choice[i] { + None => src.data[y * w + x], + Some((k, dx, dy)) => neigh[k].0.get_clamped(x as i64 + dx, y as i64 + dy), + } + }; + b.img.rows_mut().for_each(|(y, row)| { + let fy = ((y as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (ny - 1) as f64); + let (y0, ty) = (fy.floor() as usize, (fy - fy.floor()) as f32); + let y1 = (y0 + 1).min(ny - 1); + for (x, o) in row.iter_mut().enumerate() { + let fx = ((x as f64 + 0.5) / patch as f64 - 0.5).clamp(0.0, (nx - 1) as f64); + let (x0, tx) = (fx.floor() as usize, (fx - fx.floor()) as f32); + let x1 = (x0 + 1).min(nx - 1); + let ids = [y0 * nx + x0, y0 * nx + x1, y1 * nx + x0, y1 * nx + x1]; + if ids.iter().all(|&i| choice[i].is_none()) { + continue; + } + let ws = [(1.0 - tx) * (1.0 - ty), tx * (1.0 - ty), (1.0 - tx) * ty, tx * ty]; + let mut acc = [0.0f32; 4]; + for (i, wgt) in ids.iter().zip(ws) { + let q = pick(*i, x, y); + for c in 0..4 { + acc[c] += q[c] * wgt; + } + } + if show { + acc[0] = (acc[0] + 0.3 * acc[3]).min(acc[3].max(acc[0])); + } + *o = acc; + } + }); + b +} + +pub fn specs() -> Vec { + vec![ + spec( + "ec.blur.ccradial", + "CC Radial Blur", + vec![ + p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)), + p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("quality", "Quality", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), + ], + cc_radial_blur, + ), + spec( + "ec.blur.camerashakedeblur", + "Camera-Shake Deblur", + vec![ + p("blurDuration", "Blur Duration", num(2.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + p("searchRange", "Search Range", num(8.0), slider(0.0, 64.0, 0.0, 32.0, 0)), + p("patchSize", "Patch Size", num(32.0), slider(8.0, 256.0, 8.0, 128.0, 0)), + p("threshold", "Threshold", num(20.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("showBlurryFrames", "Show Blurry Frames", Value::Bool(false), ParamUi::Checkbox), + ], + camera_shake_deblur, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + + fn checker(w: u32, h: u32, cell: u32) -> Image { + crate::util::gen_image(w, h, |x, y| { + let v = if ((x as u32 / cell) + (y as u32 / cell)).is_multiple_of(2) { 1.0 } else { 0.0 }; + [v, v, v, 1.0] + }) + } + + #[test] + fn cc_radial_blur_keeps_centre_and_smears_edges() { + let img = checker(64, 64, 4); + for kind in 0..5u32 { + let out = run_fx( + "ec.blur.ccradial", + &[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + // Far from the centre the checker is smeared toward grey. + let edge = out.img.get(2, 2)[0]; + assert!(edge > 0.05 && edge < 0.95, "type {kind}: {edge}"); + let again = run_fx( + "ec.blur.ccradial", + &[("type", Value::Enum(kind)), ("amount", num(40.0)), ("center", Value::Vec2([32.0, 32.0]))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(out.img.data, again.img.data); + } + // Zero amount is identity; a constant image stays constant. + let flat = Image::filled(16, 16, [0.4, 0.4, 0.4, 1.0]); + let o = run_fx("ec.blur.ccradial", &[("amount", num(80.0))], flat.clone(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-5)); + let o = run_fx("ec.blur.ccradial", &[("amount", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(o.img.data, img.data); + } + + /// Frames: sharp checker at even frames, a blurred one at odd frames (shifted by 2 px). + struct Shaky; + impl EffectHost for Shaky { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + let f = (t * 30.0).round() as i64; + Some(Buf { img: frame(f), offset: [0.0; 2], scale: 1.0 }) + } + } + + fn frame(f: i64) -> Image { + let sharp = crate::util::gen_image(48, 48, |x, y| { + let v = if (((x + 2) / 6) + (y / 6)) % 2 == 0 { 1.0 } else { 0.0 }; + [v, v, v, 1.0] + }); + if f % 2 == 0 { sharp } else { effectcraft_raster::gaussian_blur(&sharp, 2.5, 2.5, true) } + } + + #[test] + fn camera_shake_deblur_substitutes_sharp_neighbours() { + let host = Shaky; + let env = EffectEnv { host: Some(&host), frame_rate: 30.0, ..Default::default() }; + let blurry = frame(1); + let out = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env); + let sharp = frame(0); + let err = |a: &Image| a.data.iter().zip(&sharp.data).map(|(p, q)| (p[0] - q[0]).abs()).sum::() / a.data.len() as f32; + assert!(err(&out.img) < err(&blurry) * 0.5, "{} vs {}", err(&out.img), err(&blurry)); + // A sharp frame is left alone (its neighbours are blurrier). + let keep = run_fx("ec.blur.camerashakedeblur", &[], sharp.clone(), 0.0, env); + assert_eq!(keep.img.data, sharp.data); + // Deterministic; no host = pass-through. + let again = run_fx("ec.blur.camerashakedeblur", &[("patchSize", num(16.0)), ("searchRange", num(3.0))], blurry.clone(), 1.0 / 30.0, env); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.blur.camerashakedeblur", &[], blurry.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, blurry.data); + } +} diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs new file mode 100644 index 00000000..4e6b5040 --- /dev/null +++ b/crates/effects/src/distort4.rs @@ -0,0 +1,719 @@ +//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair. +//! +//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or +//! push it away (negative), with a falloff exponent, optional tiling and supersampled +//! antialiasing. +//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The +//! strokes are the effect's data (the hidden `distortionMesh` text, one stroke per line, so it +//! animates with hold keyframes and is written by the `liquify.stroke` command); replaying +//! them is deterministic and cached. Tools: warp, turbulence, twirl clockwise / counter- +//! clockwise, pucker, bloat, shift pixels, reflection, clone, reconstruction and freeze / thaw. +//! Distortion Percentage scales the mesh and Distortion Mesh Offset moves it. +//! * **Rolling Shutter Repair** — every scanline of a CMOS frame is exposed a little later than +//! the previous one, so moving content shears. Motion is measured between the neighbouring +//! frames with pyramidal Lucas–Kanade (`effectcraft-track`) on a grid of points; each pixel is +//! then resampled from where it was at the frame's mid-exposure time: +//! `out(p) = in(p + v(p)·rate·(s(p) − ½))`, with `s` the scan position (0…1 along the scan +//! direction) and `v` the per-frame velocity (Warp: one smooth velocity per scanline band; +//! Pixel Motion: a dense interpolated field). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use rayon::prelude::*; + +use crate::util::{hash1, point_in_poly}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- CC Flo Motion + +fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf { + let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)]; + if knots.iter().all(|k| k.1 == 0.0) { + return b; + } + let tile = ctx.params.b("tileEdges"); + let ss = match ctx.params.e("antialiasing") { + 0 => 1, + 1 => 2, + _ => 3, + }; + let falloff = ctx.params.f("falloff").max(0.01); + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let radius = (lw * lw + lh * lh).sqrt() * 0.5; + let src = b.img.clone(); + let (scale, off) = (b.scale, b.offset); + let map = |lx: f64, ly: f64| -> (f64, f64) { + let (mut x, mut y) = (lx, ly); + for (k, a) in knots { + if a == 0.0 { + continue; + } + let (dx, dy) = (x - k[0], y - k[1]); + let r = (dx * dx + dy * dy).sqrt() / radius; + // Weight 1 at the knot, falling off with distance. + let w = (1.0 - r.min(1.0)).powf(falloff); + // Pull: sample closer to the knot (content flows out of it); push: farther. + let s = (1.0 - a.clamp(-4.0, 0.95) * w).max(0.02); + x = k[0] + dx * s; + y = k[1] + dy * s; + } + (x, y) + }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 4]; + for sy in 0..ss { + for sx in 0..ss { + let px = x as f64 + (sx as f64 + 0.5) / ss as f64; + let py = y as f64 + (sy as f64 + 0.5) / ss as f64; + let (lx, ly) = map((px - off[0]) / scale, (py - off[1]) / scale); + let (lx, ly) = if tile { (lx.rem_euclid(lw), ly.rem_euclid(lh)) } else { (lx, ly) }; + let q = src.sample_bilinear(lx * scale + off[0], ly * scale + off[1]); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let n = (ss * ss) as f32; + *o = acc.map(|v| v / n); + } + }); + b +} + +// ---------------------------------------------------------------- Liquify + +/// Liquify tools (the stroke text uses these names). +pub const LIQUIFY_TOOLS: [&str; 12] = [ + "warp", + "turbulence", + "twirlClockwise", + "twirlCounterclockwise", + "pucker", + "bloat", + "shiftPixels", + "reflection", + "clone", + "reconstruction", + "freeze", + "thaw", +]; + +/// One recorded Liquify stroke. +#[derive(Clone, Debug, PartialEq)] +pub struct LiquifyStroke { + pub tool: usize, + /// Brush size (diameter) and pressure (0–100), turbulent jitter (0–100), layer pixels. + pub size: f64, + pub pressure: f64, + pub jitter: f64, + /// Clone offset (source − destination). + pub clone_offset: [f64; 2], + pub points: Vec<[f64; 2]>, +} + +impl LiquifyStroke { + /// Text form: `tool size pressure jitter cloneX cloneY x,y x,y …`. + pub fn to_line(&self) -> String { + let mut s = format!("{} {} {} {} {} {}", LIQUIFY_TOOLS[self.tool], self.size, self.pressure, self.jitter, self.clone_offset[0], self.clone_offset[1]); + for p in &self.points { + s.push_str(&format!(" {},{}", p[0], p[1])); + } + s + } + pub fn parse(line: &str) -> Option { + let mut it = line.split_whitespace(); + let name = it.next()?; + let tool = LIQUIFY_TOOLS.iter().position(|t| t.eq_ignore_ascii_case(name))?; + let mut f = || -> Option { it.next()?.parse().ok() }; + let (size, pressure, jitter, cx, cy) = (f()?, f()?, f()?, f()?, f()?); + let points: Vec<[f64; 2]> = it + .filter_map(|t| { + let (a, b) = t.split_once(',')?; + Some([a.parse().ok()?, b.parse().ok()?]) + }) + .collect(); + (!points.is_empty()).then_some(LiquifyStroke { tool, size, pressure, jitter, clone_offset: [cx, cy], points }) + } +} + +/// Parse the `distortionMesh` text into strokes (bad lines are skipped). +pub fn parse_strokes(s: &str) -> Vec { + s.lines().filter_map(LiquifyStroke::parse).collect() +} + +/// Displacement mesh: offsets (layer pixels) at grid nodes spaced `cell` apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Mesh { + pub nx: usize, + pub ny: usize, + pub cell: f64, + pub d: Vec<[f64; 2]>, + pub freeze: Vec, +} + +impl Mesh { + fn new(w: f64, h: f64, cell: f64) -> Mesh { + let nx = (w / cell).ceil() as usize + 1; + let ny = (h / cell).ceil() as usize + 1; + Mesh { nx, ny, cell, d: vec![[0.0; 2]; nx * ny], freeze: vec![0.0; nx * ny] } + } + /// Bilinear displacement at a layer point (zero outside the mesh). + pub fn at(&self, x: f64, y: f64) -> [f64; 2] { + let fx = x / self.cell; + let fy = y / self.cell; + if fx < 0.0 || fy < 0.0 || fx > (self.nx - 1) as f64 || fy > (self.ny - 1) as f64 { + return [0.0; 2]; + } + let (x0, y0) = ((fx.floor() as usize).min(self.nx - 2), (fy.floor() as usize).min(self.ny - 2)); + let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1)); + let (tx, ty) = (fx - x0 as f64, fy - y0 as f64); + let g = |i: usize, j: usize| self.d[j * self.nx + i]; + let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty) + } +} + +/// Replay strokes into a displacement mesh over a `w`×`h` layer. `frozen(x, y)` reports +/// mask-frozen points (Freeze Area Mask). +pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f64, f64) -> bool + Sync)) -> Mesh { + let cell = (w.max(h) / 128.0).clamp(2.0, 16.0); + let mut m = Mesh::new(w, h, cell); + for (i, f) in m.freeze.iter_mut().enumerate() { + let (x, y) = ((i % m.nx) as f64 * cell, (i / m.nx) as f64 * cell); + if frozen(x, y) { + *f = 1.0; + } + } + for (si, s) in strokes.iter().enumerate() { + let r = (s.size * 0.5).max(1.0); + let pr = (s.pressure / 100.0).clamp(0.0, 1.0); + // Dabs every r/4 along the stroke; each dab knows the local motion of the brush. + let mut dabs: Vec<([f64; 2], [f64; 2])> = vec![]; + if s.points.len() == 1 { + dabs.push((s.points[0], [0.0; 2])); + } + for seg in s.points.windows(2) { + let (a, b) = (seg[0], seg[1]); + let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let n = ((len / (r * 0.25)).ceil() as usize).max(1); + let step = [(b[0] - a[0]) / n as f64, (b[1] - a[1]) / n as f64]; + for k in 1..=n { + dabs.push(([a[0] + step[0] * k as f64, a[1] + step[1] * k as f64], step)); + } + } + for (di, (c, mv)) in dabs.iter().enumerate() { + let (gx0, gx1) = (((c[0] - r) / cell).floor().max(0.0) as usize, (((c[0] + r) / cell).ceil() as usize).min(m.nx - 1)); + let (gy0, gy1) = (((c[1] - r) / cell).floor().max(0.0) as usize, (((c[1] + r) / cell).ceil() as usize).min(m.ny - 1)); + let prev = m.d.clone(); + for gy in gy0..=gy1 { + for gx in gx0..=gx1 { + let (x, y) = (gx as f64 * cell, gy as f64 * cell); + let (dx, dy) = (x - c[0], y - c[1]); + let dist = (dx * dx + dy * dy).sqrt(); + if dist >= r { + continue; + } + let i = gy * m.nx + gx; + // Smooth brush falloff. + let fall = { + let t = 1.0 - dist / r; + t * t * (3.0 - 2.0 * t) + }; + let tool = LIQUIFY_TOOLS[s.tool]; + if tool == "freeze" || tool == "thaw" { + let v = pr * fall; + m.freeze[i] = if tool == "freeze" { m.freeze[i].max(v) } else { (m.freeze[i] - v).max(0.0) }; + continue; + } + let w = pr * fall * (1.0 - m.freeze[i]) * 0.5; + if w <= 0.0 { + continue; + } + let d = &mut m.d[i]; + match tool { + "warp" => { + d[0] -= mv[0] * w * 2.0; + d[1] -= mv[1] * w * 2.0; + } + "turbulence" => { + let j = s.jitter / 100.0 * r * 0.1; + let seed = (si as u32).wrapping_mul(7919) ^ di as u32; + d[0] += (hash1(gx as u32, gy as u32, seed) as f64 * 2.0 - 1.0) * j * w; + d[1] += (hash1(gx as u32, gy as u32, seed ^ 0x55) as f64 * 2.0 - 1.0) * j * w; + d[0] -= mv[0] * w; + d[1] -= mv[1] * w; + } + "twirlClockwise" | "twirlCounterclockwise" => { + let sgn = if tool == "twirlClockwise" { -1.0 } else { 1.0 }; + let a = sgn * 0.08 * w; + let (sn, cs) = a.sin_cos(); + d[0] += dx * cs - dy * sn - dx; + d[1] += dx * sn + dy * cs - dy; + } + "pucker" => { + d[0] += dx * 0.05 * w; + d[1] += dy * 0.05 * w; + } + "bloat" => { + d[0] -= dx * 0.05 * w; + d[1] -= dy * 0.05 * w; + } + "shiftPixels" => { + // Perpendicular to the stroke (to its left). + d[0] -= mv[1] * w * 2.0; + d[1] += mv[0] * w * 2.0; + } + "reflection" => { + // Mirror across the stroke line through the dab. + let l = (mv[0] * mv[0] + mv[1] * mv[1]).sqrt(); + if l > 1e-9 { + let n = [-mv[1] / l, mv[0] / l]; + let k = dx * n[0] + dy * n[1]; + let tgt = [-2.0 * k * n[0], -2.0 * k * n[1]]; + d[0] += (tgt[0] - d[0]) * w; + d[1] += (tgt[1] - d[1]) * w; + } + } + "clone" => { + // Copy the distortion found at the clone source. + let sx = x + s.clone_offset[0]; + let sy = y + s.clone_offset[1]; + let fx = (sx / cell).round().clamp(0.0, (m.nx - 1) as f64) as usize; + let fy = (sy / cell).round().clamp(0.0, (m.ny - 1) as f64) as usize; + let src = prev[fy * m.nx + fx]; + let tgt = [src[0] + s.clone_offset[0], src[1] + s.clone_offset[1]]; + d[0] += (tgt[0] - d[0]) * w; + d[1] += (tgt[1] - d[1]) * w; + } + // Reconstruction: relax toward no distortion. + _ => { + d[0] *= 1.0 - w; + d[1] *= 1.0 - w; + } + } + } + } + } + } + m +} + +type MeshCache = Mutex>>; + +fn cached_mesh(ctx: &EffectCtx) -> Arc { + use std::hash::{Hash, Hasher}; + let text = ctx.params.s("distortionMesh"); + let mask = ctx.params.f("freezeAreaMask").round() as usize; + let poly = mask.checked_sub(1).and_then(|i| ctx.env.masks.get(i)).cloned(); + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + ctx.layer_size[0].to_bits().hash(&mut h); + ctx.layer_size[1].to_bits().hash(&mut h); + if let Some(p) = &poly { + for q in &p.points { + q[0].to_bits().hash(&mut h); + q[1].to_bits().hash(&mut h); + } + p.inverted.hash(&mut h); + } + let key = h.finish(); + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(m) = cache.lock().ok().and_then(|c| c.get(&key).cloned()) { + return m; + } + let strokes = parse_strokes(text); + let frozen = |x: f64, y: f64| match &poly { + Some(p) => point_in_poly(&p.points, x, y) != p.inverted, + None => false, + }; + let m = Arc::new(build_mesh(&strokes, ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0), &frozen)); + if let Ok(mut c) = cache.lock() { + if c.len() > 32 { + c.clear(); + } + c.insert(key, m.clone()); + } + m +} + +fn liquify(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pct = ctx.params.f("distortionPercentage") / 100.0; + let show_freeze = ctx.params.b("viewFreezeAreaMask"); + let show_mesh = ctx.params.b("viewMesh"); + if ctx.params.s("distortionMesh").trim().is_empty() && !show_freeze && !show_mesh { + return b; + } + let mesh = cached_mesh(ctx); + let off = ctx.params.v2("distortionMeshOffset"); + let mesh_color = ctx.params.color("meshColor"); + let src = b.img.clone(); + let (scale, o) = (b.scale, b.offset); + let line_w = 0.6 / scale.max(1e-6); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - o[0]) / scale; + let ly = (y as f64 + 0.5 - o[1]) / scale; + let d = mesh.at(lx - off[0], ly - off[1]); + let (sx, sy) = (lx + d[0] * pct, ly + d[1] * pct); + let mut c = if d == [0.0; 2] { *px } else { src.sample_bilinear(sx * scale + o[0], sy * scale + o[1]) }; + if show_freeze { + let fx = ((lx - off[0]) / mesh.cell).round().clamp(0.0, (mesh.nx - 1) as f64) as usize; + let fy = ((ly - off[1]) / mesh.cell).round().clamp(0.0, (mesh.ny - 1) as f64) as usize; + let f = mesh.freeze[fy * mesh.nx + fx] as f32; + if f > 0.0 { + c[0] = c[0] * (1.0 - 0.5 * f) + 0.5 * f; + c[3] = c[3].max(0.5 * f); + } + } + if show_mesh { + // Grid lines every 4 mesh cells, displaced with the mesh. + let gs = mesh.cell * 4.0; + let (gx, gy) = ((lx + d[0] * pct - off[0]).rem_euclid(gs), (ly + d[1] * pct - off[1]).rem_euclid(gs)); + if gx < line_w || gy < line_w { + c = [mesh_color[0], mesh_color[1], mesh_color[2], 1.0]; + } + } + *px = c; + } + }); + b +} + +// ---------------------------------------------------------------- Rolling Shutter Repair + +/// Per-pixel velocity field (layer pixels per frame) on a coarse grid, from tracked points. +struct Flow { + nx: usize, + ny: usize, + cell: f64, + v: Vec<[f64; 2]>, +} + +impl Flow { + fn at(&self, x: f64, y: f64) -> [f64; 2] { + let fx = (x / self.cell - 0.5).clamp(0.0, (self.nx - 1) as f64); + let fy = (y / self.cell - 0.5).clamp(0.0, (self.ny - 1) as f64); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.nx - 1), (y0 + 1).min(self.ny - 1)); + let (tx, ty) = (fx - x0 as f64, fy - y0 as f64); + let g = |i: usize, j: usize| self.v[j * self.nx + i]; + let l = |a: [f64; 2], b: [f64; 2], t: f64| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + l(l(g(x0, y0), g(x1, y0), tx), l(g(x0, y1), g(x1, y1), tx), ty) + } +} + +fn median(v: &mut [f64]) -> f64 { + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same +/// buffer grid). `per_row`: Warp method (one velocity per scanline band). +fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option { + use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track}; + let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 }); + let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4); + let (w, h) = (pc.width(), pc.height()); + let n = if detailed { 24 } else { 12 }; + let cell_w = w as f64 / n as f64; + let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize; + let cell_h = h as f64 / rows as f64; + let pts: Vec<[f64; 2]> = (0..rows).flat_map(|j| (0..n).map(move |i| [(i as f64 + 0.5) * cell_w, (j as f64 + 0.5) * cell_h])).collect(); + let o = LkOpts { radius: 4, fb_max: 1.0, ..Default::default() }; + let go = |other: Option<&Image>| other.map(|img| track(&pc, &GrayPyramid::from_image(img, [0.0; 2], factor, 4), &pts, None, &o)); + let (tn, tp) = (go(next), go(prev)); + // Velocity in plane pixels per frame. + let vel: Vec> = (0..pts.len()) + .map(|i| { + let p = pts[i]; + let fw = tn.as_ref().and_then(|t| t[i]).map(|q| [q[0] - p[0], q[1] - p[1]]); + let bw = tp.as_ref().and_then(|t| t[i]).map(|q| [p[0] - q[0], p[1] - q[1]]); + match (fw, bw) { + (Some(a), Some(b)) => Some([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]), + (a, b) => a.or(b), + } + }) + .collect(); + let valid: Vec<[f64; 2]> = vel.iter().flatten().copied().collect(); + if valid.is_empty() { + return None; + } + let mut vx: Vec = valid.iter().map(|v| v[0]).collect(); + let mut vy: Vec = valid.iter().map(|v| v[1]).collect(); + let global = [median(&mut vx), median(&mut vy)]; + let k = factor as f64 / scale.max(1e-9); + let mut v = vec![[0.0; 2]; n * rows]; + for j in 0..rows { + // Row (band) median for Warp; per point (outliers replaced) for Pixel Motion. + let band: Vec<[f64; 2]> = (0..n).filter_map(|i| vel[j * n + i]).collect(); + let row_med = if band.is_empty() { + global + } else { + let mut a: Vec = band.iter().map(|v| v[0]).collect(); + let mut b: Vec = band.iter().map(|v| v[1]).collect(); + [median(&mut a), median(&mut b)] + }; + for i in 0..n { + let s = if per_row { + row_med + } else { + match vel[j * n + i] { + Some(q) if (q[0] - row_med[0]).abs() + (q[1] - row_med[1]).abs() < 3.0 + 0.5 * (row_med[0].abs() + row_med[1].abs()) => q, + _ => row_med, + } + }; + v[j * n + i] = [s[0] * k, s[1] * k]; + } + } + // Cell size in layer pixels (grid is uniform in plane pixels; assume square plane pixels). + Some(Flow { nx: n, ny: rows, cell: cell_w * k, v }) +} + +fn same_grid(nb: Buf, b: &Buf) -> Image { + if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { + return nb.img; + } + let k = nb.scale / b.scale.max(1e-9); + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; + let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; + nb.img.sample_bilinear(lx, ly) + }) +} + +fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let rate = ctx.params.f("rollingShutterRate") / 100.0; + if rate == 0.0 { + return b; + } + let Some(host) = ctx.env.host else { return b }; + let fps = ctx.fps(); + let prev = host.self_at(ctx.time - 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b)); + let next = host.self_at(ctx.time + 1.0 / fps, ctx.env.effect_index).map(|nb| same_grid(nb, &b)); + if prev.is_none() && next.is_none() { + return b; + } + let per_row = ctx.params.e("method") == 0; + let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("detailedAnalysis"), per_row) else { return b }; + let dir = ctx.params.e("scanDirection"); + let src = b.img.clone(); + let (w, h) = (b.img.width as f64, b.img.height as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let s = match dir { + 1 => 1.0 - fy / h, + 2 => fx / w, + 3 => 1.0 - fx / w, + _ => fy / h, + }; + let tau = rate * (s - 0.5); + let v = flow.at(fx, fy); + *px = src.sample_bilinear_clamped(fx + v[0] * tau, fy + v[1] * tau); + } + }); + b +} + +pub fn specs() -> Vec { + vec![ + spec( + "ec.distort.ccflomotion", + "CC Flo Motion", + vec![ + p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point), + p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)), + p("knot2", "Knot 2", Value::Vec2([0.75, 0.5]), ParamUi::Point), + p("amount2", "Amount 2", num(-50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)), + p("tileEdges", "Tile Edges", Value::Bool(false), ParamUi::Checkbox), + p("antialiasing", "Antialiasing", Value::Enum(1), popup(&["Low", "Medium", "High"])), + p("falloff", "Falloff", num(2.0), slider(0.01, 10.0, 0.1, 5.0, 2)), + ], + flo_motion, + ), + spec( + "ec.distort.liquify", + "Liquify", + vec![ + p("tool", "Tool", Value::Enum(0), popup(&LIQUIFY_TOOLS)), + p("brushSize", "Brush Size", num(64.0), slider(1.0, 600.0, 1.0, 600.0, 0)), + p("brushPressure", "Brush Pressure", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)), + p("freezeAreaMask", "Freeze Area Mask", num(0.0), ParamUi::Mask), + p("turbulentJitter", "Turbulent Jitter", num(70.0), slider(1.0, 100.0, 1.0, 100.0, 0)), + p("cloneOffset", "Clone Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden), + p("reconstructionMode", "Reconstruction Mode", Value::Enum(0), popup(&["Revert", "Displace", "Amplitwist", "Affine"])), + p("viewFreezeAreaMask", "View Freeze Area Mask", Value::Bool(false), ParamUi::Checkbox), + p("viewMesh", "View Mesh", Value::Bool(false), ParamUi::Checkbox), + p("meshSize", "Mesh Size", Value::Enum(1), popup(&["Small", "Medium", "Large"])), + p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color), + // The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command. + p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden), + p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden), + p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])), + ], + liquify, + ), + spec( + "ec.distort.rollingshutterrepair", + "Rolling Shutter Repair", + vec![ + p("rollingShutterRate", "Rolling Shutter Rate", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("scanDirection", "Scan Direction", Value::Enum(0), popup(&["Top → Bottom", "Bottom → Top", "Left → Right", "Right → Left"])), + p("method", "Method", Value::Enum(0), popup(&["Warp", "Pixel Motion"])), + p("detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox), + p("pixelMotionDetail", "Pixel Motion Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + rolling_shutter, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + + fn grid_img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| { + let v = if x % 8 == 0 || y % 8 == 0 { 1.0 } else { 0.2 }; + [v, v * 0.5, x as f32 / w as f32, 1.0] + }) + } + + #[test] + fn flo_motion_pulls_and_pushes() { + let img = grid_img(64, 64); + let out = run_fx( + "ec.distort.ccflomotion", + &[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + // The knot itself is fixed; pixels near it are magnified (sampled closer to the knot). + assert!((out.img.get(32, 32)[2] - img.get(32, 32)[2]).abs() < 0.01); + assert!((out.img.get(40, 32)[2] - img.get(40, 32)[2]).abs() > 0.01); + assert!(out.img.get(40, 32)[2] < img.get(40, 32)[2]); + let none = run_fx("ec.distort.ccflomotion", &[("amount1", num(0.0)), ("amount2", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, img.data); + let again = run_fx( + "ec.distort.ccflomotion", + &[("knot1", Value::Vec2([32.0, 32.0])), ("amount1", num(80.0)), ("amount2", num(0.0))], + img, + 0.0, + EffectEnv::default(), + ); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn liquify_strokes_round_trip_and_warp() { + let s = LiquifyStroke { tool: 0, size: 30.0, pressure: 80.0, jitter: 70.0, clone_offset: [0.0, 0.0], points: vec![[20.0, 32.0], [40.0, 32.0]] }; + assert_eq!(LiquifyStroke::parse(&s.to_line()), Some(s.clone())); + assert!(LiquifyStroke::parse("nope 1 2 3 4 5 1,2").is_none()); + let img = grid_img(64, 64); + let out = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default()); + assert_ne!(out.img.data, img.data); + // Warping to the right: a pixel right of the start now shows content from its left. + let at = |i: &Image| i.get(36, 32)[2]; + assert!(at(&out.img) < at(&img), "{} vs {}", at(&out.img), at(&img)); + // Far away, untouched. + assert_eq!(out.img.get(5, 5), img.get(5, 5)); + // Distortion Percentage 0 = identity; deterministic. + let zero = run_fx( + "ec.distort.liquify", + &[("distortionMesh", Value::Str(s.to_line())), ("distortionPercentage", num(0.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(zero.img.data, img.data); + let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + // Reconstruction undoes the warp; freeze protects. + let mut rec = s.clone(); + rec.tool = 9; + rec.pressure = 100.0; + let lines = format!("{}\n{}\n{}\n{}", s.to_line(), rec.to_line(), rec.to_line(), rec.to_line()); + let m1 = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| false); + let m2 = build_mesh(&parse_strokes(&lines), 64.0, 64.0, &|_, _| false); + let mag = |m: &Mesh| m.d.iter().map(|d| d[0].abs() + d[1].abs()).sum::(); + assert!(mag(&m2) < mag(&m1) * 0.5); + let frozen = build_mesh(&parse_strokes(&s.to_line()), 64.0, 64.0, &|_, _| true); + assert_eq!(mag(&frozen), 0.0); + // Every tool changes (or deliberately keeps) the mesh without NaNs. + for t in 0..LIQUIFY_TOOLS.len() { + let st = LiquifyStroke { tool: t, clone_offset: [10.0, 0.0], ..s.clone() }; + let m = build_mesh(&[s.clone(), st], 64.0, 64.0, &|_, _| false); + assert!(m.d.iter().all(|d| d[0].is_finite() && d[1].is_finite()), "{}", LIQUIFY_TOOLS[t]); + } + } + + /// A textured frame panning right at 3 px per frame, captured with a rolling shutter: row + /// `y` is exposed `(y / h − ½)` frames late (rate 100 %). + fn texture(x: f64, y: f64) -> f32 { + let a = ((x * 0.31).sin() * (y * 0.23).cos() + (x * 0.07 + y * 0.11).sin()) as f32; + 0.5 + 0.25 * a + } + fn rs_frame(f: f64, shutter: bool) -> Image { + let (w, h) = (96u32, 64u32); + crate::util::gen_image(w, h, |x, y| { + let tau = if shutter { (y as f64 + 0.5) / h as f64 - 0.5 } else { 0.0 }; + let shift = 3.0 * (f + tau); + let v = texture(x as f64 + 0.5 - shift, y as f64 + 0.5); + [v, v, v, 1.0] + }) + } + struct Pan; + impl EffectHost for Pan { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: rs_frame(t * 24.0, true), offset: [0.0; 2], scale: 1.0 }) + } + } + + #[test] + fn rolling_shutter_repair_unskews_a_pan() { + let host = Pan; + let env = EffectEnv { host: Some(&host), frame_rate: 24.0, ..Default::default() }; + let t = 5.0 / 24.0; + let skewed = rs_frame(5.0, true); + let truth = rs_frame(5.0, false); + let err = |a: &Image| { + let mut s = 0.0; + let mut n = 0.0; + for y in 4..60 { + for x in 12..84 { + s += (a.get(x, y)[0] - truth.get(x, y)[0]).abs(); + n += 1.0; + } + } + s / n + }; + for method in [0u32, 1] { + let out = run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env); + assert!(err(&out.img) < err(&skewed) * 0.4, "method {method}: {} vs {}", err(&out.img), err(&skewed)); + let again = + run_fx("ec.distort.rollingshutterrepair", &[("rollingShutterRate", num(100.0)), ("method", Value::Enum(method))], skewed.clone(), t, env); + assert_eq!(out.img.data, again.img.data); + } + let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default()); + assert_eq!(none.img.data, skewed.data); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 14a43464..746500e6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,6 +8,7 @@ pub mod audio_fx; mod blur2; +mod blur3; mod channel; mod channel2; mod color2; @@ -17,6 +18,7 @@ mod controls; mod distort; mod distort2; mod distort3; +pub mod distort4; mod generate; mod generate2; mod generate3; @@ -356,6 +358,8 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(obsolete::specs()); v.extend(ocio::specs()); v.extend(vr::specs()); + v.extend(blur3::specs()); + v.extend(distort4::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); @@ -472,6 +476,8 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.vr.digitalglitch", + "ec.blur.camerashakedeblur", + "ec.distort.rollingshutterrepair", ]; /// See [`TIME_DEPENDENT`]. diff --git a/crates/engine/src/commands/liquify.rs b/crates/engine/src/commands/liquify.rs new file mode 100644 index 00000000..748c664c --- /dev/null +++ b/crates/engine/src/commands/liquify.rs @@ -0,0 +1,156 @@ +//! Liquify brush strokes (`liquify.stroke`, `liquify.clear`). +//! +//! A stroke is appended to the layer's Liquify effect (added when the layer has none) as one +//! line of its hidden Distortion Mesh data (see `effectcraft_effects::distort4`). Brush size, +//! pressure, turbulent jitter and clone offset default to the effect's own tool options. When +//! the Distortion Mesh is animated, the stroke goes into a keyframe at the current time (copying +//! the mesh in effect there), as After Effects records mesh keyframes. + +use effectcraft_effects::distort4::{LIQUIFY_TOOLS, LiquifyStroke, parse_strokes}; +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::build::Ids; +use effectcraft_project::{GroupKind, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +const ID: &str = "ec.distort.liquify"; + +fn is_liquify(g: &PropGroup) -> bool { + matches!(&g.kind, GroupKind::Effect { effect } if effect == ID) +} + +fn liquify_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2], want: Option) -> Result { + if let Some(u) = want { + return fx.groups().find(|g| g.uid == u && is_liquify(g)).map(|g| g.uid).ok_or_else(|| bad("liquify.stroke", "no such Liquify effect")); + } + if let Some(g) = fx.groups().filter(|g| is_liquify(g)).last() { + return Ok(g.uid); + } + let spec = effectcraft_effects::find(ID).ok_or_else(|| EngineError::Other("Liquify effect missing".into()))?; + let g = effectcraft_effects::instantiate(spec, ids, "Liquify", layer_size); + let uid = g.uid; + fx.children.push(g.into()); + Ok(uid) +} + +fn num_of(g: &PropGroup, id: &str, default: f64) -> f64 { + g.get(id).map(|p| p.value.as_f64()).unwrap_or(default) +} + +fn stroke(s: &mut Session, p: &Value) -> Result { + let cmd = "liquify.stroke"; + let (cid, lid) = layer_p(s, p, cmd)?; + let tool_name = str_p(p, "tool").unwrap_or("warp"); + let tool = LIQUIFY_TOOLS + .iter() + .position(|t| t.eq_ignore_ascii_case(tool_name)) + .ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?; + let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?; + let mut points = vec![]; + for q in raw { + let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?; + let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else { + return Err(bad(cmd, "each point is [x, y]")); + }; + points.push([x, y]); + } + if points.is_empty() { + return Err(bad(cmd, "`points` is empty")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp))); + let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?; + if layer.effects().is_none() || !layer.source.is_av() { + return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers")); + } + let lt = layer.layer_time(t); + let (w, h) = effectcraft_render::source_size(&s.project, layer); + let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }; + let want = p.get("effect").and_then(Value::as_u64); + let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?; + let mut ids = Ids(&mut next); + let fx_uid = liquify_effect(fx, &mut ids, size, want)?; + proj.next_id = next; + let l = layer_mut(proj, cid, lid)?; + let g = l.props.sub_mut("effects").and_then(|fx| fx.find_group_mut(fx_uid)).ok_or_else(|| bad(cmd, "Liquify effect gone"))?; + let co = g.get("cloneOffset").map(|p| p.value.as_vec2()).unwrap_or([0.0; 2]); + let st = LiquifyStroke { + tool, + size: f_p(p, "size").unwrap_or_else(|| num_of(g, "brushSize", 64.0)).max(1.0), + pressure: f_p(p, "pressure").unwrap_or_else(|| num_of(g, "brushPressure", 50.0)).clamp(1.0, 100.0), + jitter: f_p(p, "jitter").unwrap_or_else(|| num_of(g, "turbulentJitter", 70.0)).clamp(1.0, 100.0), + clone_offset: match p.get("cloneOffset").and_then(Value::as_array) { + Some(a) => [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)], + None => co, + }, + points: points.clone(), + }; + let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "Liquify effect has no Distortion Mesh"))?; + let append = |cur: &str| if cur.trim().is_empty() { st.to_line() } else { format!("{}\n{}", cur.trim_end(), st.to_line()) }; + let text = if prop.keys.is_empty() { + let cur = match &prop.value { + KV::Str(x) => x.clone(), + _ => String::new(), + }; + let v = append(&cur); + prop.value = KV::Str(v.clone()); + v + } else { + // Hold keyframes: the mesh in effect at `lt` is the last key at or before it. + let cur = prop.keys.iter().rfind(|k| k.time <= lt).or(prop.keys.first()).map(|k| k.value.clone()); + let cur = match cur { + Some(KV::Str(x)) => x, + _ => String::new(), + }; + let v = append(&cur); + match prop.keys.iter_mut().find(|k| k.time == lt) { + Some(k) => k.value = KV::Str(v.clone()), + None => { + prop.keys.push(Keyframe::new(lt, KV::Str(v.clone()))); + prop.keys.sort_by_key(|k| k.time); + } + } + v + }; + Ok((fx_uid, parse_strokes(&text).len())) + })?; + Ok(json!({"effect": fx_uid, "strokes": count})) +} + +fn clear(s: &mut Session, p: &Value) -> Result { + let cmd = "liquify.clear"; + let (cid, lid) = layer_p(s, p, cmd)?; + let want = p.get("effect").and_then(Value::as_u64); + s.edit("Clear Liquify Mesh", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?; + let uid = + fx.groups().filter(|g| is_liquify(g) && want.is_none_or(|u| u == g.uid)).last().map(|g| g.uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?; + let g = fx.find_group_mut(uid).ok_or_else(|| bad(cmd, "no Liquify effect"))?; + let prop = g.get_mut("distortionMesh").ok_or_else(|| bad(cmd, "no Distortion Mesh"))?; + prop.keys.clear(); + prop.value = KV::Str(String::new()); + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "liquify.stroke", + "Liquify Stroke", + [], + None, + "{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}", + has_comp, + stroke + ), + cmd!("liquify.clear", "Clear Liquify Mesh", [], None, "{layer?, effect?: uid}", has_comp, clear), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 26db85c9..4f0910ba 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -17,6 +17,7 @@ mod layer; mod layer_menu; mod layer_time; mod link; +mod liquify; mod lottie; mod markers; mod mask; @@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(track::specs()); v.extend(paint::specs()); v.extend(puppet::specs()); + v.extend(liquify::specs()); v.extend(settings::specs()); v.extend(stubs::specs()); v diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index e274f725..9926e04c 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -227,3 +227,26 @@ fn project_with_paint_and_puppet_round_trips() { let v: Value = serde_json::from_str(&j).unwrap(); assert!(v.to_string().contains("ec.paint.paint") && v.to_string().contains("ec.distort.puppet")); } + +#[test] +fn liquify_stroke_adds_the_effect_and_warps() { + let (mut s, id) = setup(); + // Layer (5, 30) is comp (55, 50). Before: opaque blue. + assert!(px(&s, 0.0, 55, 50)[3] > 0.99); + let r = s.execute("liquify.stroke", json!({"layer": id, "tool": "warp", "points": [[-20, 30], [10, 30]], "size": 40, "pressure": 100})).unwrap(); + assert_eq!(r["strokes"], 1); + let l = layer(&s, id); + let fx = l.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&fx.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.distort.liquify")); + // The warp pulls transparent pixels from outside the layer over its left edge. + assert!(px(&s, 0.0, 55, 50)[3] < 0.9, "{:?}", px(&s, 0.0, 55, 50)); + // A second stroke goes into the same effect. + let r2 = s.execute("liquify.stroke", json!({"layer": id, "tool": "reconstruction", "points": [[0, 30]], "size": 60, "pressure": 100})).unwrap(); + assert_eq!(r2["strokes"], 2); + assert_eq!(r2["effect"], r["effect"]); + assert!(s.execute("liquify.stroke", json!({"layer": id, "tool": "smudge", "points": [[0, 0]]})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, id).effects().unwrap().groups().next().is_none()); + assert!(px(&s, 0.0, 55, 50)[3] > 0.99); +} diff --git a/docs/architecture.md b/docs/architecture.md index c3d478e3..f7819a7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L5 | `automation` | The MCP server, headless or bridged to the running app | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | -Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`, +Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`, `media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`. ## 2. Time diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae38adf9..76ff6fa3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -55,6 +55,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("effects", "project"), ("effects", "text"), ("effects", "path"), + ("effects", "track"), ("media", "render"), ("expr", "render"), ("export", "render"), From f95a600722208543ae88d5477d2f389a1a099664 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:11:50 -0400 Subject: [PATCH 05/10] M9.11: Key Light (Keylight 1.2-equivalent keyer) and mocha shape Key Light is registered under a generic name (Keylight is a vendor trademark); lookup() resolves "Keylight (1.2)" to it. mocha shape reads a documented JSON shape format (Mocha's own export format is not public). Co-Authored-By: Claude Opus 5.5 --- crates/effects/Cargo.toml | 3 +- crates/effects/src/keylight.rs | 455 ++++++++++++++++++++++++++++++ crates/effects/src/lib.rs | 10 +- crates/effects/src/mocha_shape.rs | 243 ++++++++++++++++ 4 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 crates/effects/src/keylight.rs create mode 100644 crates/effects/src/mocha_shape.rs diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index e321fa2f..ff5d3ed7 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -17,6 +17,5 @@ effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-track = { workspace = true } rayon = { workspace = true } - -[dev-dependencies] serde_json = { workspace = true } + diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs new file mode 100644 index 00000000..980a6169 --- /dev/null +++ b/crates/effects/src/keylight.rs @@ -0,0 +1,455 @@ +//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled +//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as +//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]). +//! +//! The method is the textbook *screen difference* key, written from the public description of +//! the controls: +//! +//! * colours are first neutralised by the **Alpha Bias** / **Despill Bias** colours (each channel +//! scaled so the bias colour becomes grey — mid grey = no change); +//! * the **screen difference** of a pixel is its screen-primary channel minus a balance-weighted +//! mix of the other two (**Screen Balance**); the raw matte is +//! `1 − Screen Gain × diff(pixel) / diff(screen colour)`; +//! * the **Screen Matte** is then clipped (Clip Black / White, with Clip Rollback restoring edge +//! detail), despotted (morphological open/close: Despot Black / White), shrunk or grown and +//! softened, combined with the **Inside** / **Outside** masks and the source alpha; +//! * the foreground is the source with the screen colour removed in proportion to the +//! transparency (`(c − (1 − α)·S) / α`), then **despilled** (the primary is limited to the +//! balanced secondary), with **Replace Method** colouring areas whose alpha was raised by +//! clipping or the inside mask, and optional foreground and edge colour correction. +//! +//! Views: Source, Source Alpha, Corrected Source, Colour Correction Edges, Screen Matte, Inside +//! Mask, Outside Mask, Combined Matte, Status, Intermediate Result and Final Result. + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, gaussian_blur}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +/// Other names the effect answers to in [`crate::lookup`]. +pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"]; + +pub const VIEWS: [&str; 11] = [ + "Source", + "Source Alpha", + "Corrected Source", + "Colour Correction Edges", + "Screen Matte", + "Inside Mask", + "Outside Mask", + "Combined Matte", + "Status", + "Intermediate Result", + "Final Result", +]; + +const REPLACE: [&str; 4] = ["None", "Source", "Hard Colour", "Soft Colour"]; + +fn pct(d: f64) -> (Value, ParamUi) { + (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)) +} + +fn mask_popup() -> ParamUi { + popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"]) +} + +/// Index of the screen's primary channel and the two others (larger-in-screen first). +fn primary(s: [f32; 3]) -> (usize, usize, usize) { + let pi = if s[1] >= s[0] && s[1] >= s[2] { + 1 + } else if s[2] >= s[0] { + 2 + } else { + 0 + }; + let (a, b) = match pi { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + if s[a] >= s[b] { (pi, a, b) } else { (pi, b, a) } +} + +/// Screen difference: primary minus the balance-weighted secondaries. +#[inline] +pub fn screen_diff(c: [f32; 3], pi: usize, o1: usize, o2: usize, bal: f32) -> f32 { + c[pi] - (bal * c[o1] + (1.0 - bal) * c[o2]) +} + +/// Scale channels so `bias` becomes neutral (grey keeps everything as is). +#[inline] +fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] { + let l = luminance(bias[0], bias[1], bias[2]).max(1e-4); + [0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4)) +} + +fn mask_coverage(ctx: &EffectCtx, b: &Buf, idx: u32, softness: f64, invert: bool) -> Option { + let shape = ctx.env.masks.get((idx as usize).checked_sub(1)?)?; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut pl = Plane::new(w, h); + let inv = 1.0 / b.scale.max(1e-9); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + *v = if point_in_poly(&shape.points, lx, ly) != shape.inverted { 1.0 } else { 0.0 }; + } + }); + let s = softness * b.scale; + if s > 0.0 { + pl = gauss_plane(&pl, s * 0.5, s * 0.5); + } + if invert { + pl = pl.map(|v| 1.0 - v); + } + Some(pl) +} + +/// Saturation / contrast / brightness correction of a straight colour. +fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] { + let l = luminance(c[0], c[1], c[2]); + let c = c.map(|v| l + (v - l) * sat); + c.map(|v| ((v - 0.5) * contrast + 0.5) * bright) +} + +fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let view = pr.e("view"); + if view == 0 { + return b; + } + let sc = pr.color("screenColour"); + let screen = [sc[0], sc[1], sc[2]]; + let (pi, o1, o2) = primary(screen); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let dbias = pr.color("despillBias"); + let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") }; + let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]); + let s_alpha = neutralise(screen, abias); + let sd = screen_diff(s_alpha, pi, o1, o2, bal).max(1e-3); + let cb = pr.f("clipBlack") as f32 / 100.0; + let cw = pr.f("clipWhite") as f32 / 100.0; + let rollback = pr.f("clipRollback") * b.scale; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenShrinkGrow") * b.scale; + let soft = pr.f("screenSoftness") * b.scale; + let despot_b = pr.f("screenDespotBlack") * b.scale; + let despot_w = pr.f("screenDespotWhite") * b.scale; + + let src = b.img.clone(); + let blurred; + let key_src: &Image = if preblur > 0.0 { + blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true); + &blurred + } else { + &src + }; + // Raw screen matte (opacity of the foreground). + let raw = Plane::from_image(key_src, |px| { + let (c, _) = unpremul(px); + let d = screen_diff(neutralise(c, abias), pi, o1, o2, bal); + (1.0 - gain * d / sd).clamp(0.0, 1.0) + }); + let clip = |v: f32| ((v - cb) / (cw - cb).max(1e-4)).clamp(0.0, 1.0); + let mut m = raw.map(clip); + if rollback > 0.0 { + // Restore edge detail lost by clipping within `rollback` pixels of the clipped matte. + let grown = morph_frac(&m, rollback, true); + let shrunk = morph_frac(&m, rollback, false); + m = Plane { + w: m.w, + h: m.h, + data: (0..m.data.len()) + .into_par_iter() + .map(|i| if grown.data[i] > shrunk.data[i] + 1e-4 { m.data[i].max(raw.data[i]).min(grown.data[i]) } else { m.data[i] }) + .collect(), + }; + } + if despot_b > 0.0 { + // Fill small holes: close (dilate then erode). + m = morph_frac(&morph_frac(&m, despot_b, true), despot_b, false); + } + if despot_w > 0.0 { + // Remove small specks: open (erode then dilate). + m = morph_frac(&morph_frac(&m, despot_w, false), despot_w, true); + } + if sg < 0.0 { + m = morph_frac(&m, -sg, false); + } else if sg > 0.0 { + m = morph_frac(&m, sg, true); + } + if soft > 0.0 { + m = gauss_plane(&m, soft * 0.5, soft * 0.5); + } + let screen_matte = m.clone(); + let inside = mask_coverage(ctx, &b, pr.e("insideMask"), pr.f("insideMaskSoftness"), pr.b("invertInsideMask")); + let outside = mask_coverage(ctx, &b, pr.e("outsideMask"), pr.f("outsideMaskSoftness"), pr.b("invertOutsideMask")); + let src_alpha_mode = pr.e("sourceAlpha"); + let n = src.data.len(); + let combined: Vec = (0..n) + .into_par_iter() + .map(|i| { + let mut a = screen_matte.data[i]; + let sa = src.data[i][3].clamp(0.0, 1.0); + let ins = inside.as_ref().map_or(0.0, |p| p.data[i]); + let ins = if src_alpha_mode == 1 { ins.max(sa) } else { ins }; + a = a.max(ins); + if let Some(o) = &outside { + a *= 1.0 - o.data[i]; + } + if src_alpha_mode == 2 { + a *= sa; + } + a.clamp(0.0, 1.0) + }) + .collect(); + let edges: Option> = pr.b("enableEdgeColourCorrection").then(|| { + let grow = pr.f("edgeGrow") * b.scale; + let hard = (pr.f("edgeHardness") / 100.0) as f32; + let esoft = pr.f("edgeSoftness") * b.scale; + // Edge band: where the (grown) combined matte is partial. + let cm = Plane { w: m.w, h: m.h, data: combined.clone() }; + let band = cm.map(|a| if a > 1e-3 && a < 0.999 { 1.0 } else { 0.0 }); + let mut band = if grow > 0.0 { morph_frac(&band, grow, true) } else { band }; + if esoft > 0.0 { + band = gauss_plane(&band, esoft * 0.5, esoft * 0.5); + } + band.data.iter().map(|v| (v * (1.0 + hard * 4.0)).min(1.0)).collect() + }); + let replace = pr.e("replaceMethod"); + let rc = pr.color("replaceColour"); + let in_replace = pr.e("insideReplaceMethod"); + let in_rc = pr.color("insideReplaceColour"); + let fg_cc = pr.b("enableColourCorrection"); + let (fs, fc, fbr) = (pr.f("saturation") as f32 / 100.0, pr.f("contrast") as f32 / 100.0 + 1.0, pr.f("brightness") as f32 / 100.0 + 1.0); + let (es, ec, ebr) = (pr.f("edgeSaturation") as f32 / 100.0, pr.f("edgeContrast") as f32 / 100.0 + 1.0, pr.f("edgeBrightness") as f32 / 100.0 + 1.0); + let unpremultiply = pr.b("unpremultiplyResult"); + let crops = [pr.f("cropLeft"), pr.f("cropRight"), pr.f("cropTop"), pr.f("cropBottom")].map(|v| v / 100.0); + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let w = b.img.width as usize; + let (scale, off) = (b.scale, b.offset); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, _) = unpremul(src.data[i]); + let a = combined[i]; + let sm = screen_matte.data[i]; + // Source crops (layer fractions from each edge). + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9)); + let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]; + // Screen removal on the raw (unclipped) transparency, then despill. + let ar = raw.data[i].max(1e-4); + let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0)); + let nfg = neutralise(fg, dbias); + let spill = screen_diff(nfg, pi, o1, o2, bal).max(0.0); + let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4); + fg[pi] -= spill / l0; + let intermediate = fg; + // Replace colour where the alpha was raised beyond the raw screen matte. + let raised = (sm - raw.data[i]).max(0.0); + let raised_in = inside.as_ref().map_or(0.0, |p| (p.data[i] - sm).max(0.0)); + let apply_replace = |fg: [f32; 3], method: u32, rc: [f32; 4], amt: f32| -> [f32; 3] { + if amt <= 0.0 { + return fg; + } + match method { + 1 => [0, 1, 2].map(|k| fg[k] + (c[k] - fg[k]) * amt), + 2 => [rc[0], rc[1], rc[2]], + 3 => { + let l = luminance(c[0], c[1], c[2]) / luminance(rc[0], rc[1], rc[2]).max(1e-4); + [0, 1, 2].map(|k| fg[k] + (rc[k] * l - fg[k]) * amt) + } + _ => fg, + } + }; + fg = apply_replace(fg, replace, rc, raised); + fg = apply_replace(fg, in_replace, in_rc, raised_in); + if fg_cc { + fg = correct(fg, fs, fc, fbr); + } + let edge = edges.as_ref().map_or(0.0, |e| e[i]); + if edge > 0.0 { + let ce = correct(fg, es, ec, ebr); + fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge); + } + let grey = |v: f32| [v, v, v, 1.0]; + *px = match view { + 1 => grey(src.data[i][3]), + 2 => { + let mut cs = neutralise(c, dbias); + let s2 = screen_diff(cs, pi, o1, o2, bal).max(0.0); + cs[pi] -= s2; + if fg_cc { + cs = correct(cs, fs, fc, fbr); + } + [cs[0], cs[1], cs[2], 1.0] + } + 3 => grey(edge), + 4 => grey(sm), + 5 => grey(inside.as_ref().map_or(0.0, |p| p.data[i])), + 6 => grey(outside.as_ref().map_or(0.0, |p| p.data[i])), + 7 => grey(a), + 8 => { + let v = if a <= 1e-3 { + 0.0 + } else if a >= 0.999 { + 1.0 + } else { + 0.5 + }; + grey(v) + } + 9 => premul(intermediate, sm), + _ => { + if cropped { + [0.0; 4] + } else if unpremultiply { + premul(fg, a) + } else { + // Keylight's premultiplied output read as straight colour. + premul(fg.map(|v| v * a), a) + } + } + }; + }); + b +} + +pub fn specs() -> Vec { + let pp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1); + let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)); + vec![EffectSpec { + id: "ec.keying.keylight", + name: "Key Light", + category: "Keying", + params: vec![ + p("view", "View", Value::Enum(10), popup(&VIEWS)), + p("unpremultiplyResult", "Unpremultiply Result", Value::Bool(true), ParamUi::Checkbox), + p("screenColour", "Screen Colour", col(0.0, 1.0, 0.0), ParamUi::Color), + p("screenGain", "Screen Gain", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("screenBalance", "Screen Balance", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("despillBias", "Despill Bias", col(0.5, 0.5, 0.5), ParamUi::Color), + p("alphaBias", "Alpha Bias", col(0.5, 0.5, 0.5), ParamUi::Color), + p("lockBiasesTogether", "Lock Biases Together", Value::Bool(true), ParamUi::Checkbox), + p("screenPreblur", "Screen Pre-blur", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 1)), + // Screen Matte + pp("clipBlack", "Clip Black", pct(0.0)), + pp("clipWhite", "Clip White", pct(100.0)), + p("clipRollback", "Clip Rollback", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("screenShrinkGrow", "Screen Shrink/Grow", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 1)), + p("screenSoftness", "Screen Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("screenDespotBlack", "Screen Despot Black", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("screenDespotWhite", "Screen Despot White", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("replaceMethod", "Replace Method", Value::Enum(3), popup(&REPLACE)), + p("replaceColour", "Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color), + // Inside Mask + p("insideMask", "Inside Mask", Value::Enum(0), mask_popup()), + p("insideMaskSoftness", "Inside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("invertInsideMask", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("insideReplaceMethod", "Inside Replace Method", Value::Enum(1), popup(&REPLACE)), + p("insideReplaceColour", "Inside Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color), + p("sourceAlpha", "Source Alpha", Value::Enum(2), popup(&["Ignore", "Add to Inside Mask", "Normal"])), + // Outside Mask + p("outsideMask", "Outside Mask", Value::Enum(0), mask_popup()), + p("outsideMaskSoftness", "Outside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("invertOutsideMask", "Invert Outside Mask", Value::Bool(false), ParamUi::Checkbox), + // Foreground Colour Correction + p("enableColourCorrection", "Enable Colour Correction", Value::Bool(false), ParamUi::Checkbox), + p("saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + cc("contrast", "Contrast"), + cc("brightness", "Brightness"), + // Edge Colour Correction + p("enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox), + pp("edgeHardness", "Edge Hardness", pct(0.0)), + p("edgeSoftness", "Edge Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("edgeGrow", "Edge Grow", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("edgeSaturation", "Edge Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + cc("edgeContrast", "Edge Contrast"), + cc("edgeBrightness", "Edge Brightness"), + // Source Crops + pp("cropLeft", "Left", pct(0.0)), + pp("cropRight", "Right", pct(0.0)), + pp("cropTop", "Top", pct(0.0)), + pp("cropBottom", "Bottom", pct(0.0)), + ], + render: key_light, + gpu: false, + float: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, run_fx}; + + /// Green screen with a red square (fg) and a half-transparent mix strip. + fn plate() -> Image { + crate::util::gen_image(40, 30, |x, y| { + if (10..20).contains(&x) && (10..20).contains(&y) { + [0.8, 0.2, 0.1, 1.0] + } else if (25..30).contains(&x) { + // 50 % red over green. + [0.4, 0.6, 0.05, 1.0] + } else { + [0.1, 0.9, 0.1, 1.0] + } + }) + } + + fn green() -> Value { + col(0.1, 0.9, 0.1) + } + + #[test] + fn keys_the_screen_and_keeps_the_foreground() { + let out = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default()); + let bg = out.img.get(2, 2); + let fg = out.img.get(15, 15); + let mix = out.img.get(27, 5); + assert!(bg[3] < 0.02, "{bg:?}"); + assert!(fg[3] > 0.98 && (fg[0] - 0.8).abs() < 0.05, "{fg:?}"); + assert!(mix[3] > 0.3 && mix[3] < 0.7, "{mix:?}"); + // The recovered edge colour has the green removed (no spill). + let (c, _) = unpremul(mix); + assert!(c[1] <= c[0] + 0.05, "{c:?}"); + // Deterministic. + let again = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn clip_gain_views_and_masks() { + // Clip White 60 %: the half-transparent strip becomes solid. + let out = run_fx("ec.keying.keylight", &[("screenColour", green()), ("clipWhite", num(40.0))], plate(), 0.0, EffectEnv::default()); + assert!(out.img.get(27, 5)[3] > 0.99); + // Screen Matte and Status views are grey-scale and opaque. + let sm = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(4))], plate(), 0.0, EffectEnv::default()); + assert!(sm.img.get(2, 2)[0] < 0.02 && sm.img.get(15, 15)[0] > 0.98 && sm.img.get(2, 2)[3] == 1.0); + let st = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(8))], plate(), 0.0, EffectEnv::default()); + assert_eq!(st.img.get(27, 5)[0], 0.5); + // Source view is the identity. + let src = run_fx("ec.keying.keylight", &[("view", Value::Enum(0))], plate(), 0.0, EffectEnv::default()); + assert_eq!(src.img.data, plate().data); + // Inside mask forces opacity; outside mask forces transparency. + let sq = |x0: f64, y0: f64, x1: f64, y1: f64| MaskShape { + name: "m".into(), + points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], + closed: true, + inverted: false, + }; + let masks = [sq(0.0, 0.0, 6.0, 6.0), sq(12.0, 12.0, 18.0, 18.0)]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let m = run_fx("ec.keying.keylight", &[("screenColour", green()), ("insideMask", Value::Enum(1)), ("outsideMask", Value::Enum(2))], plate(), 0.0, env); + assert!(m.img.get(2, 2)[3] > 0.99, "{:?}", m.img.get(2, 2)); + assert!(m.img.get(15, 15)[3] < 0.01); + // Screen gain above 100 % keys more of the strip. + let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default()); + assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]); + assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight")); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 746500e6..4a4c46ee 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -24,8 +24,10 @@ mod generate2; mod generate3; mod keying; mod keying2; +pub mod keylight; mod matte; mod misc; +pub mod mocha_shape; mod noise; mod noise2; mod obsolete; @@ -360,6 +362,8 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(vr::specs()); v.extend(blur3::specs()); v.extend(distort4::specs()); + v.extend(keylight::specs()); + v.extend(mocha_shape::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); @@ -380,7 +384,10 @@ pub fn find(id: &str) -> Option<&'static EffectSpec> { /// Find by id or (case-insensitive) display name. pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> { - find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) + find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))).or_else(|| { + // After Effects' third-party display names we register under a generic name. + keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten() + }) } /// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the @@ -478,6 +485,7 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.vr.digitalglitch", "ec.blur.camerashakedeblur", "ec.distort.rollingshutterrepair", + "ec.obsolete.mochashape", ]; /// See [`TIME_DEPENDENT`]. diff --git a/crates/effects/src/mocha_shape.rs b/crates/effects/src/mocha_shape.rs new file mode 100644 index 00000000..0fafce9e --- /dev/null +++ b/crates/effects/src/mocha_shape.rs @@ -0,0 +1,243 @@ +//! Obsolete ▸ mocha shape: renders planar-tracked roto shapes as a matte. +//! +//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape +//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the +//! JSON itself or a path to a `.json` file), which converters or agents can produce: +//! +//! ```json +//! {"shapes": [{"name": "Shape 1", "closed": true, "inverted": false, "feather": 2.0, +//! "points": [[x, y], …], +//! "frames": [{"time": 0.0, "points": [[x, y], …]}, {"time": 1.0, "points": […]}]}]} +//! ``` +//! +//! Coordinates are layer pixels. `frames` (layer time in seconds, linearly interpolated between +//! keys with the same vertex count, held outside) override the static `points`. Shapes combine +//! with Blend Mode (add / subtract / intersect / difference) into a coverage matte that is used +//! per Render Type: Shape Cutout (layer × matte), Color Composite (shape colour over the layer) +//! or Color Shape Cutout (shape colour inside the matte only). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, point_in_poly}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MochaShape { + pub name: String, + pub closed: bool, + pub inverted: bool, + pub feather: f64, + pub points: Vec<[f64; 2]>, + /// (layer time, points), sorted by time. + pub frames: Vec<(f64, Vec<[f64; 2]>)>, +} + +impl MochaShape { + /// Outline at layer time `t`. + pub fn at(&self, t: f64) -> Vec<[f64; 2]> { + if self.frames.is_empty() { + return self.points.clone(); + } + let i = self.frames.partition_point(|f| f.0 <= t); + if i == 0 { + return self.frames[0].1.clone(); + } + if i == self.frames.len() { + return self.frames[i - 1].1.clone(); + } + let (a, b) = (&self.frames[i - 1], &self.frames[i]); + if a.1.len() != b.1.len() { + return a.1.clone(); + } + let u = ((t - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0); + a.1.iter().zip(&b.1).map(|(p, q)| [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]).collect() + } +} + +fn pts(v: &serde_json::Value) -> Vec<[f64; 2]> { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|p| { + let p = p.as_array()?; + Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Parse shape data JSON (see the module docs). `None` when it is not valid. +pub fn parse_shapes(text: &str) -> Option> { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + let list = v.get("shapes").and_then(|s| s.as_array()).or_else(|| v.as_array())?; + let mut out = vec![]; + for s in list { + let mut frames: Vec<(f64, Vec<[f64; 2]>)> = s + .get("frames") + .and_then(|f| f.as_array()) + .map(|a| a.iter().filter_map(|f| Some((f.get("time")?.as_f64()?, pts(f.get("points")?)))).collect()) + .unwrap_or_default(); + frames.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.push(MochaShape { + name: s.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(), + closed: s.get("closed").and_then(|c| c.as_bool()).unwrap_or(true), + inverted: s.get("inverted").and_then(|c| c.as_bool()).unwrap_or(false), + feather: s.get("feather").and_then(|c| c.as_f64()).unwrap_or(0.0).max(0.0), + points: s.get("points").map(pts).unwrap_or_default(), + frames, + }); + } + Some(out) +} + +type ShapeCache = Mutex>>>>; + +fn load(src: &str) -> Option>> { + let s = src.trim(); + if s.is_empty() { + return None; + } + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(v) = cache.lock().ok().and_then(|c| c.get(src).cloned()) { + return v; + } + let parsed = + if s.starts_with('{') || s.starts_with('[') { parse_shapes(s) } else { std::fs::read_to_string(s).ok().and_then(|t| parse_shapes(&t)) }.map(Arc::new); + if let Ok(mut c) = cache.lock() { + if c.len() > 64 { + c.clear(); + } + c.insert(src.to_string(), parsed.clone()); + } + parsed +} + +fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(shapes) = load(ctx.params.s("shapeData")) else { return b }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let blend = ctx.params.e("blendMode"); + let inv = 1.0 / b.scale.max(1e-9); + let mut matte: Option = None; + for s in shapes.iter() { + let outline = s.at(ctx.time); + if outline.len() < 3 || !s.closed { + continue; + } + let mut cov = Plane::new(w, h); + cov.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + *v = if point_in_poly(&outline, lx, ly) != s.inverted { 1.0 } else { 0.0 }; + } + }); + if s.feather > 0.0 { + let f = s.feather * b.scale * 0.5; + cov = gauss_plane(&cov, f, f); + } + matte = Some(match matte { + None => cov, + Some(m) => m.zip_map(&cov, |a, c| match blend { + 1 => a * (1.0 - c), + 2 => a * c, + 3 => (a - c).abs(), + _ => a + c - a * c, + }), + }); + } + let Some(mut m) = matte else { return b }; + if ctx.params.b("invert") { + m = m.map(|v| 1.0 - v); + } + let kind = ctx.params.e("renderType"); + let c = ctx.params.color("shapeColour"); + let op = (ctx.params.f("opacity") / 100.0) as f32; + let edge = ctx.params.b("renderEdgeWidth"); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| { + let k = if edge { (1.0 - (2.0 * k - 1.0).abs()).clamp(0.0, 1.0) } else { k.clamp(0.0, 1.0) }; + match kind { + 1 => { + let a = k * op; + let s = [c[0] * a, c[1] * a, c[2] * a, a]; + *px = [s[0] + px[0] * (1.0 - a), s[1] + px[1] * (1.0 - a), s[2] + px[2] * (1.0 - a), s[3] + px[3] * (1.0 - a)]; + } + 2 => { + let a = k * op; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; + } + _ => { + let a = 1.0 - (1.0 - k) * op; + *px = px.map(|v| v * a); + } + } + }); + b +} + +pub fn specs() -> Vec { + vec![EffectSpec { + id: "ec.obsolete.mochashape", + name: "mocha shape", + category: "Obsolete", + params: vec![ + p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("renderEdgeWidth", "Render edge width", Value::Bool(false), ParamUi::Checkbox), + p("renderType", "Render type", Value::Enum(0), popup(&["Shape cutout", "Color composite", "Color shape cutout"])), + p("shapeColour", "Shape colour", col(1.0, 1.0, 1.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + // JSON shape data (see the module docs) or a path to a .json file. + p("shapeData", "Shape data", Value::Str(String::new()), ParamUi::Hidden), + ], + render: mocha_shape, + gpu: false, + float: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, Image, run_fx}; + + const DATA: &str = r#"{"shapes":[{"name":"S","closed":true,"points":[[2,2],[10,2],[10,10],[2,10]], + "frames":[{"time":0,"points":[[2,2],[10,2],[10,10],[2,10]]},{"time":1,"points":[[12,2],[20,2],[20,10],[12,10]]}]}]}"#; + + #[test] + fn parses_and_interpolates() { + let s = parse_shapes(DATA).unwrap(); + assert_eq!(s.len(), 1); + assert_eq!(s[0].at(0.5)[0], [7.0, 2.0]); + assert_eq!(s[0].at(5.0)[0], [12.0, 2.0]); + assert!(parse_shapes("not json").is_none()); + } + + #[test] + fn cuts_out_the_shape_over_time() { + let img = Image::filled(24, 12, [1.0, 0.0, 0.0, 1.0]); + let a = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img.get(5, 5)[3], 1.0); + assert_eq!(a.img.get(15, 5)[3], 0.0); + let b = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 1.0, EffectEnv::default()); + assert_eq!(b.img.get(5, 5)[3], 0.0); + assert_eq!(b.img.get(15, 5)[3], 1.0); + // Color shape cutout fills with the shape colour; no data = pass-through. + let c = run_fx( + "ec.obsolete.mochashape", + &[("shapeData", Value::Str(DATA.into())), ("renderType", Value::Enum(2)), ("shapeColour", col(0.0, 1.0, 0.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(c.img.get(5, 5), [0.0, 1.0, 0.0, 1.0]); + let none = run_fx("ec.obsolete.mochashape", &[], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, img.data); + } +} From 7b815bcbb206e1a1f77b851cf76956eb0d97a624 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:18:33 -0400 Subject: [PATCH 06/10] M9.11: Simulation effects (Particle Playground, CC Hair) Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall and persistent property mapper; deterministic, stepped from t=0 through the checkpointed SimCache. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 + crates/effects/src/sim3.rs | 716 +++++++++++++++++++++++++++++++++++++ 2 files changed, 718 insertions(+) create mode 100644 crates/effects/src/sim3.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4a4c46ee..2f6a463e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -38,6 +38,7 @@ mod perspective2; pub mod puppet; mod sim; mod sim2; +mod sim3; mod stylize2; mod stylize3; mod textfx; @@ -364,6 +365,7 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(distort4::specs()); v.extend(keylight::specs()); v.extend(mocha_shape::specs()); + v.extend(sim3::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs new file mode 100644 index 00000000..66e9e11c --- /dev/null +++ b/crates/effects/src/sim3.rs @@ -0,0 +1,716 @@ +//! Simulation: Particle Playground and CC Hair. +//! +//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at +//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as +//! playing up to it). Producers: **Cannon** (a stream from a barrel with angle, radius, rate, +//! velocity and random spreads), **Grid** (particles placed on a grid at time 0) and **Layer +//! Exploder** (another layer broken into particles at time 0). Forces: **Gravity** (force, +//! random spread, direction), **Repel** (particles push each other apart within a radius), +//! **Wall** (a mask of the layer the particles bounce off) and the **Persistent Property +//! Mapper** (a map layer's red / green / blue drive particle properties at the particle's +//! position each step). **Layer Map** draws particles with the colours of another layer +//! sampled at each particle's birth position. Map layers are read at the frame being rendered +//! (their pixels are part of the cache key). Particles replace the layer's own pixels, as in +//! After Effects. +//! +//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a +//! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall +//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a +//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector). + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::sim::{Acc, SPS, Shape, Sprite, combine, splat, steps_at}; +use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- Particle Playground + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PgParticle { + pub p: [f32; 2], + pub v: [f32; 2], + /// Layer position where the particle was born (Layer Map lookups). + pub origin: [f32; 2], + pub c: [f32; 4], + pub r: f32, + pub mass: f32, + pub friction: f32, + pub force: [f32; 2], + pub id: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct PgState { + pub parts: Vec, + carry: f64, + next_id: u32, +} + +/// A map layer sampled in layer coordinates. +struct MapImg { + img: Image, + /// Layer point → pixel: (x · k₀ + o₀, y · k₁ + o₁). + k: [f64; 2], + o: [f64; 2], +} + +impl MapImg { + fn from(ctx: &EffectCtx, lp: crate::LayerPixels) -> MapImg { + // Fit the map layer to this layer (stretch), like AE's layer maps. + let (ls, os) = (ctx.layer_size, lp.size); + let sx = if ls[0] > 0.0 { os[0] / ls[0] } else { 1.0 }; + let sy = if ls[1] > 0.0 { os[1] / ls[1] } else { 1.0 }; + MapImg { img: lp.buf.img, k: [lp.buf.scale * sx, lp.buf.scale * sy], o: lp.buf.offset } + } + fn at(&self, x: f32, y: f32) -> Px { + let (c, a) = unpremul(self.img.sample_bilinear_clamped(x as f64 * self.k[0] + self.o[0], y as f64 * self.k[1] + self.o[1])); + [c[0], c[1], c[2], a] + } +} + +pub const MAP_TARGETS: [&str; 12] = + ["None", "Red", "Green", "Blue", "Scale", "Opacity", "X Velocity", "Y Velocity", "X Force", "Y Force", "Kinetic Friction", "Mass"]; + +struct Pg { + // Cannon + cannon: bool, + pos: [f32; 2], + barrel_angle: f32, + barrel_radius: f32, + rate: f64, + dir_spread: f32, + vel: f32, + vel_spread: f32, + color: [f32; 4], + radius: f32, + // Gravity + gravity: [f32; 2], + grav_spread: f32, + // Repel + repel: f32, + repel_radius: f32, + // Wall + wall: Option>, + // Property mapper + mapper: Option, + map_to: [(u32, f32, f32); 3], + seed: u32, + bounds: [f32; 4], +} + +impl Pg { + fn spawn(&self, id: u32) -> PgParticle { + let s = self.seed; + let h = |k: u32| hash1(id, k, s) * 2.0 - 1.0; + // AE angles: 0° = up, clockwise. + let a = (self.barrel_angle + h(1) * self.dir_spread * 0.5).to_radians(); + let d = [a.sin(), -a.cos()]; + let perp = [-d[1], d[0]]; + let off = h(2) * self.barrel_radius; + let speed = (self.vel + h(3) * self.vel_spread).max(0.0); + let p = [self.pos[0] + perp[0] * off, self.pos[1] + perp[1] * off]; + PgParticle { p, v: [d[0] * speed, d[1] * speed], origin: p, c: self.color, r: self.radius, mass: 1.0, friction: 0.0, force: [0.0; 2], id } + } + + fn step(&self, st: &mut PgState) { + let dt = (1.0 / SPS) as f32; + let s = self.seed; + // Property mapper (persistent): drive properties from the map at the particle position. + if let Some(m) = &self.mapper { + st.parts.par_iter_mut().for_each(|q| { + let px = m.at(q.p[0], q.p[1]); + for (ch, &(target, lo, hi)) in self.map_to.iter().enumerate() { + let v = lo + (hi - lo) * px[ch]; + match target { + 1 => q.c[0] = v, + 2 => q.c[1] = v, + 3 => q.c[2] = v, + 4 => q.r = self.radius * v.max(0.0), + 5 => q.c[3] = v.clamp(0.0, 1.0), + 6 => q.v[0] = v, + 7 => q.v[1] = v, + 8 => q.force[0] = v, + 9 => q.force[1] = v, + 10 => q.friction = v.clamp(0.0, 1.0), + 11 => q.mass = v.max(0.01), + _ => {} + } + } + }); + } + // Repel: spatial hash, symmetric pairwise push within the radius. + let mut push = vec![[0.0f32; 2]; st.parts.len()]; + if self.repel != 0.0 && self.repel_radius > 0.0 && st.parts.len() > 1 { + let cell = self.repel_radius; + let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); + for (i, q) in st.parts.iter().enumerate() { + grid.entry(((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32)).or_default().push(i); + } + let parts = &st.parts; + push.par_iter_mut().enumerate().for_each(|(i, f)| { + let q = parts[i]; + let (cx, cy) = ((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32); + for gy in cy - 1..=cy + 1 { + for gx in cx - 1..=cx + 1 { + let Some(list) = grid.get(&(gx, gy)) else { continue }; + for &j in list { + if j == i { + continue; + } + let o = parts[j]; + let (dx, dy) = (q.p[0] - o.p[0], q.p[1] - o.p[1]); + let d = (dx * dx + dy * dy).sqrt(); + if d < self.repel_radius && d > 1e-4 { + let k = self.repel * (1.0 - d / self.repel_radius) / d; + f[0] += dx * k; + f[1] += dy * k; + } + } + } + } + }); + } + let wall = self.wall.as_deref(); + st.parts.par_iter_mut().zip(push.par_iter()).for_each(|(q, f)| { + let g = if self.grav_spread > 0.0 { 1.0 + (hash1(q.id, 7, s) * 2.0 - 1.0) * self.grav_spread } else { 1.0 }; + let ax = self.gravity[0] * g + (q.force[0] + f[0]) / q.mass; + let ay = self.gravity[1] * g + (q.force[1] + f[1]) / q.mass; + let damp = 1.0 - q.friction * 0.1; + q.v = [(q.v[0] + ax * dt) * damp, (q.v[1] + ay * dt) * damp]; + let np = [q.p[0] + q.v[0] * dt, q.p[1] + q.v[1] * dt]; + if let Some(w) = wall { + let was = point_in_poly(w, q.p[0] as f64, q.p[1] as f64); + let now = point_in_poly(w, np[0] as f64, np[1] as f64); + if was != now { + // Bounce: reverse the velocity and stay on the same side. + q.v = [-q.v[0] * 0.9, -q.v[1] * 0.9]; + return; + } + } + q.p = np; + }); + let b = self.bounds; + st.parts.retain(|q| q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3]); + if self.cannon { + st.carry += self.rate / SPS; + let n = st.carry.floor() as u32; + st.carry -= n as f64; + for _ in 0..n { + if st.parts.len() >= 60_000 { + break; + } + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + let mut q = self.spawn(id); + // Sub-step birth offset keeps streams smooth. + let frac = hash1(id, 9, s) * dt; + q.p = [q.p[0] + q.v[0] * frac, q.p[1] + q.v[1] * frac]; + st.parts.push(q); + } + } + } +} + +static PG_CACHE: SimCache = SimCache::new(6); + +fn image_key(img: &Image) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + img.width.hash(&mut h); + img.height.hash(&mut h); + for p in img.data.iter().step_by(7) { + for c in p { + c.to_bits().hash(&mut h); + } + } + h.finish() +} + +fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let grav_dir = (pr.f("gravityDirection") as f32).to_radians(); + let gf = pr.f("gravityForce") as f32; + let wall = (pr.f("wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); + let mapper = if pr.b("mapperEnabled") { ctx.layer_param("useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; + let pg = Pg { + cannon: pr.b("cannonEnabled"), + pos: { pr.v2("cannonPosition").map(|v| v as f32) }, + barrel_angle: pr.f("barrelAngle") as f32, + barrel_radius: pr.f("barrelRadius") as f32, + rate: pr.f("particlesPerSecond").max(0.0), + dir_spread: pr.f("directionRandomSpread") as f32, + vel: pr.f("velocity") as f32, + vel_spread: pr.f("velocityRandomSpread") as f32, + color: pr.color("cannonColor"), + radius: pr.f("cannonParticleRadius").max(0.0) as f32, + gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf], + grav_spread: (pr.f("gravityForceRandomSpread") / 100.0).max(0.0) as f32, + repel: pr.f("repelForce") as f32 * 100.0, + repel_radius: pr.f("repelForceRadius").max(0.0) as f32, + wall, + mapper, + map_to: [ + (pr.e("mapRedTo"), pr.f("redMin") as f32, pr.f("redMax") as f32), + (pr.e("mapGreenTo"), pr.f("greenMin") as f32, pr.f("greenMax") as f32), + (pr.e("mapBlueTo"), pr.f("blueMin") as f32, pr.f("blueMax") as f32), + ], + seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, + bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], + }; + // Initial particles: Grid and Layer Exploder. + let grid_on = pr.b("gridEnabled"); + let across = pr.f("particlesAcross").max(0.0).round() as u32; + let down = pr.f("particlesDown").max(0.0).round() as u32; + let gpos = pr.v2("gridPosition"); + let (gw, gh) = (pr.f("gridWidth"), pr.f("gridHeight")); + let gcol = pr.color("gridColor"); + let grad = pr.f("gridParticleRadius").max(0.0) as f32; + let exploder = if pr.b("exploderEnabled") { + let host = ctx.env.host; + let lid = pr.get("explodeLayer").and_then(Value::as_layer); + match (host, lid) { + (Some(h), Some(id)) => h.layer_at(id, ctx.env.comp_time - ctx.time, true).or_else(|| h.layer(id, true)), + _ => None, + } + } else { + None + }; + let ex_r = pr.f("radiusOfNewParticles").max(0.5) as f32; + let ex_disp = pr.f("velocityDispersion") as f32; + let layer_map = pr.b("layerMapEnabled").then(|| ctx.layer_param("layerMapLayer", true).map(|lp| MapImg::from(ctx, lp))).flatten(); + + let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); + if let Some(m) = &pg.mapper { + key ^= image_key(&m.img).rotate_left(7); + } + if let Some(e) = &exploder { + key ^= image_key(&e.buf.img).rotate_left(13); + } + if let Some(w) = &pg.wall { + key ^= w.iter().fold(0u64, |a, p| a.rotate_left(5) ^ p[0].to_bits() ^ p[1].to_bits().rotate_left(17)); + } + let seed = pg.seed; + let init = || { + let mut st = PgState::default(); + if grid_on && across > 0 && down > 0 { + for j in 0..down { + for i in 0..across { + let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 }; + let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 }; + let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32]; + let id = st.next_id; + st.next_id += 1; + st.parts.push(PgParticle { p, v: [0.0; 2], origin: p, c: gcol, r: grad, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + } + } + } + if let Some(e) = &exploder { + let m = MapImg::from(ctx, e.clone()); + let step = (ex_r * 2.0).max(1.0); + let mut y = step * 0.5; + while y < lh { + let mut x = step * 0.5; + while x < lw { + let c = m.at(x, y); + if c[3] > 0.05 { + let id = st.next_id; + st.next_id += 1; + let h = |k: u32| hash1(id, k, seed) * 2.0 - 1.0; + let (dx, dy) = (x - lw * 0.5, y - lh * 0.5); + let l = (dx * dx + dy * dy).sqrt().max(1.0); + let v = [dx / l * ex_disp + h(1) * ex_disp, dy / l * ex_disp + h(2) * ex_disp]; + st.parts.push(PgParticle { p: [x, y], v, origin: [x, y], c, r: ex_r, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + } + x += step; + } + y += step; + } + } + st + }; + let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + let s = b.scale as f32; + let sprites: Vec = st + .parts + .iter() + .map(|q| { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + let c = match &layer_map { + Some(m) => { + let mc = m.at(q.origin[0], q.origin[1]); + [mc[0], mc[1], mc[2], mc[3] * q.c[3]] + } + None => q.c, + }; + Sprite::new(x as f32, y as f32, q.r * s, c, Shape::Disc) + }) + .collect(); + let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + // Particles replace the layer's own pixels. + b.img = combine(&b.img, &fx, 5); + b +} + +// ---------------------------------------------------------------- CC Hair + +fn cc_hair(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let len = pr.f("length").max(0.0) as f32; + let density = pr.f("density").max(0.0) as f32 / 100.0; + if len <= 0.0 || density <= 0.0 { + return b; + } + let thick = pr.f("thickness").max(0.05) as f32; + let weight = pr.f("weight") as f32; + let const_mass = pr.b("constantMass"); + let map = ctx.layer_param("mapLayer", true).map(|lp| MapImg::from(ctx, lp)); + let map_strength = (pr.f("mapStrength") / 100.0) as f32; + let map_soft = pr.f("mapSoftness").max(0.0) as f32; + let noise = (pr.f("addNoise") / 100.0) as f32; + let hc = pr.color("hairColor"); + let bright = (pr.f("brightness") / 100.0) as f32; + let opacity = (pr.f("opacity") / 100.0) as f32; + let inherit = (pr.f("colorInheritance") / 100.0) as f32; + let light_i = (pr.f("lightIntensity") / 100.0) as f32; + let ld = (pr.f("lightDirection") as f32).to_radians(); + let light = [ld.sin(), -ld.cos()]; + let (amb, dif, spe) = ((pr.f("ambient") / 100.0) as f32, (pr.f("diffuse") / 100.0) as f32, (pr.f("specular") / 100.0) as f32); + let rough = (pr.f("roughness") / 100.0).clamp(0.01, 1.0) as f32; + let seed = ctx.seed ^ (pr.f("randomSeed") as u32).wrapping_mul(0x85eb_ca6b); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + // Roots: a jittered grid over the layer, density per 1 000 px². + let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32; + let src = b.img.clone(); + let segs = 6u32; + let strands: Vec> = (0..count) + .into_par_iter() + .filter_map(|i| { + let rx = hash1(i, 1, seed) * lw; + let ry = hash1(i, 2, seed) * lh; + let (bx, by) = b.to_px([rx as f64, ry as f64]); + let root = src.sample_bilinear(bx, by); + if root[3] < 0.5 { + return None; + } + let (rc, _) = unpremul(root); + let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed)); + // Lean: random, plus the map's local gradient (hairfall map). + let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4]; + if let Some(m) = &map { + let lum = |x: f32, y: f32| { + let c = m.at(x, y); + (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * c[3] + }; + let d = map_soft.max(1.0); + let (gx, gy) = (lum(rx + d, ry) - lum(rx - d, ry), lum(rx, ry + d) - lum(rx, ry - d)); + l *= 1.0 - map_strength + map_strength * lum(rx, ry); + lean = [lean[0] + gx * map_strength * 4.0, lean[1] + gy * map_strength * 4.0]; + if noise > 0.0 { + lean[0] += (hash1(i, 6, seed) * 2.0 - 1.0) * noise; + } + } + let ll = (lean[0] * lean[0] + lean[1] * lean[1]).sqrt().max(1e-4); + let dir = [lean[0] / ll, lean[1] / ll]; + // Droop: heavier (or thinner with Constant Mass off) hair bends more. + let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l; + let base = [rc[0] + (hc[0] - rc[0]) * (1.0 - inherit), rc[1] + (hc[1] - rc[1]) * (1.0 - inherit), rc[2] + (hc[2] - rc[2]) * (1.0 - inherit)]; + let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s]; + let mut out = Vec::with_capacity(segs as usize); + for k in 0..segs { + let (s0, s1) = (k as f32 / segs as f32, (k + 1) as f32 / segs as f32); + let (a, c) = (pt(s0), pt(s1)); + let t = { + let (tx, ty) = (c[0] - a[0], c[1] - a[1]); + let tl = (tx * tx + ty * ty).sqrt().max(1e-6); + [tx / tl, ty / tl] + }; + // Strand shading (Kajiya–Kay): diffuse ∝ sin(T, L), specular from T·L. + let tl = t[0] * light[0] + t[1] * light[1]; + let diff = (1.0 - tl * tl).max(0.0).sqrt(); + let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe; + let shade = (amb + dif * diff * light_i) * bright; + let tip = 1.0 - 0.6 * s1; + let c3 = [base[0] * shade + spec * light_i, base[1] * shade + spec * light_i, base[2] * shade + spec * light_i]; + let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64])); + out.push(Sprite { + x: pa.0 as f32, + y: pa.1 as f32, + r: thick * 0.5 * b.scale as f32 * tip, + c: [c3[0], c3[1], c3[2], opacity], + shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 }, + rot: 0.0, + }); + } + Some(out) + }) + .collect(); + let sprites: Vec = strands.into_iter().flatten().collect(); + let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + b.img = combine(&b.img, &fx, 0); + b +} + +pub fn specs() -> Vec { + let pt = |x: f64, y: f64| Value::Vec2([x, y]); + let px = |d: f64, max: f64| (num(d), slider(0.0, max * 10.0, 0.0, max, 1)); + let sp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1); + let mut pg = vec![ + // Cannon + p("cannonEnabled", "Cannon", Value::Bool(true), ParamUi::Checkbox), + p("cannonPosition", "Position", pt(0.5, 0.9), ParamUi::Point), + p("barrelAngle", "Barrel Angle", num(0.0), ParamUi::Angle), + sp("barrelRadius", "Barrel Radius", px(0.0, 100.0)), + sp("particlesPerSecond", "Particles Per Second", px(60.0, 500.0)), + sp("directionRandomSpread", "Direction Random Spread", px(20.0, 360.0)), + sp("velocity", "Velocity", px(130.0, 1000.0)), + sp("velocityRandomSpread", "Velocity Random Spread", px(20.0, 500.0)), + p("cannonColor", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + sp("cannonParticleRadius", "Particle Radius", px(2.0, 50.0)), + // Grid + p("gridEnabled", "Grid", Value::Bool(true), ParamUi::Checkbox), + p("gridPosition", "Grid Position", pt(0.5, 0.5), ParamUi::Point), + sp("gridWidth", "Width", px(100.0, 2000.0)), + sp("gridHeight", "Height", px(100.0, 2000.0)), + sp("particlesAcross", "Particles Across", px(0.0, 100.0)), + sp("particlesDown", "Particles Down", px(0.0, 100.0)), + p("gridColor", "Grid Color", col(1.0, 1.0, 1.0), ParamUi::Color), + sp("gridParticleRadius", "Grid Particle Radius", px(2.0, 50.0)), + // Layer Exploder + p("exploderEnabled", "Layer Exploder", Value::Bool(true), ParamUi::Checkbox), + p("explodeLayer", "Explode Layer", Value::Layer(None), ParamUi::Layer), + sp("radiusOfNewParticles", "Radius of New Particles", px(2.0, 50.0)), + sp("velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), + // Layer Map + p("layerMapEnabled", "Layer Map", Value::Bool(true), ParamUi::Checkbox), + p("layerMapLayer", "Use Layer", Value::Layer(None), ParamUi::Layer), + // Gravity + sp("gravityForce", "Force", px(108.0, 1000.0)), + sp("gravityForceRandomSpread", "Force Random Spread", px(0.0, 100.0)), + p("gravityDirection", "Direction", num(180.0), ParamUi::Angle), + // Repel + p("repelForce", "Repel Force", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)), + sp("repelForceRadius", "Force Radius", px(0.0, 100.0)), + // Wall + p("wallBoundary", "Boundary", num(0.0), ParamUi::Mask), + // Persistent Property Mapper + p("mapperEnabled", "Persistent Property Mapper", Value::Bool(true), ParamUi::Checkbox), + p("useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer), + ]; + for (to, mn, mx, n_to, n_min, n_max) in [ + ("mapRedTo", "redMin", "redMax", "Map Red To", "Red Min", "Red Max"), + ("mapGreenTo", "greenMin", "greenMax", "Map Green To", "Green Min", "Green Max"), + ("mapBlueTo", "blueMin", "blueMax", "Map Blue To", "Blue Min", "Blue Max"), + ] { + pg.push(p(to, n_to, Value::Enum(0), popup(&MAP_TARGETS))); + pg.push(p(mn, n_min, num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + pg.push(p(mx, n_max, num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + } + pg.push(p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0))); + let pc = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)); + vec![ + spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground), + spec( + "ec.sim.cchair", + "CC Hair", + vec![ + sp("length", "Length", px(30.0, 200.0)), + p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)), + p("weight", "Weight", num(0.2), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("constantMass", "Constant Mass", Value::Bool(false), ParamUi::Checkbox), + sp("density", "Density", px(100.0, 1000.0)), + // Hairfall Map + sp("mapStrength", "Map Strength", pc(0.0)), + p("mapLayer", "Map Layer", Value::Layer(None), ParamUi::Layer), + sp("mapSoftness", "Map Softness", px(0.0, 100.0)), + sp("addNoise", "Add Noise", pc(0.0)), + // Hair Color + p("hairColor", "Color", col(0.35, 0.25, 0.15), ParamUi::Color), + sp("brightness", "Brightness", px(100.0, 400.0)), + sp("opacity", "Opacity", pc(100.0)), + sp("colorInheritance", "Color Inheritance", pc(0.0)), + // Light + sp("lightIntensity", "Light Intensity", px(100.0, 400.0)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + // Shading + sp("ambient", "Ambient", pc(40.0)), + sp("diffuse", "Diffuse", pc(60.0)), + sp("specular", "Specular", pc(30.0)), + sp("roughness", "Roughness", pc(20.0)), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), + ], + cc_hair, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, run_fx}; + + fn run(vals: &[(&str, Value)], t: f64) -> Image { + run_fx("ec.sim.particleplayground", vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), t, EffectEnv::default()).img + } + + fn cannon(t: f64) -> Image { + run(&[("cannonPosition", Value::Vec2([40.0, 50.0])), ("velocity", num(60.0)), ("gravityForce", num(30.0))], t) + } + + #[test] + fn cannon_stream_is_seek_consistent() { + let a = cannon(1.0); + // Particles replace the layer: some coverage, mostly transparent, red. + let cov: f32 = a.data.iter().map(|p| p[3]).sum(); + assert!(cov > 5.0, "{cov}"); + assert!(a.data.iter().any(|p| p[3] > 0.5 && p[0] > 0.5 && p[1] < 0.1)); + // Seek back and forth: the same frame every time. + let _ = cannon(2.0); + let b = cannon(1.0); + assert_eq!(a.data, b.data); + let c = cannon(0.5); + assert_ne!(a.data, c.data); + // Nothing has been fired at time 0. + let z = cannon(0.0); + assert!(z.data.iter().all(|p| p[3] == 0.0)); + } + + #[test] + fn grid_falls_with_gravity_and_bounces_off_a_wall() { + let grid = |t: f64, env: EffectEnv| { + run_fx( + "ec.sim.particleplayground", + &[ + ("cannonEnabled", Value::Bool(false)), + ("gridPosition", Value::Vec2([40.0, 10.0])), + ("gridWidth", num(40.0)), + ("gridHeight", num(0.0)), + ("particlesAcross", num(5.0)), + ("particlesDown", num(1.0)), + ("gravityForce", num(100.0)), + ("wallBoundary", num(1.0)), + ], + Image::new(80, 60), + t, + env, + ) + .img + }; + // Alpha-weighted mean height of the particles. + let row_of = |img: &Image| { + let (mut s, mut n) = (0.0f64, 0.0f64); + for y in 0..60 { + for x in 0..80 { + let a = img.get(x, y)[3] as f64; + s += (y as f64 + 0.5) * a; + n += a; + } + } + (s / n.max(1e-9)).round() as i64 + }; + let t0 = grid(0.0, EffectEnv::default()); + let t1 = grid(0.5, EffectEnv::default()); + assert_eq!(row_of(&t0), 10); + // ½ g t² = 12.5 px. + assert!((row_of(&t1) - 22).abs() <= 2, "{}", row_of(&t1)); + // A wall (mask 1) whose floor is at y = 15 keeps them above it. + let masks = [MaskShape { name: "w".into(), points: vec![[0.0, 0.0], [80.0, 0.0], [80.0, 15.0], [0.0, 15.0]], closed: true, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let w = grid(1.5, env); + assert!(row_of(&w) <= 16, "{}", row_of(&w)); + } + + #[test] + fn repel_spreads_particles() { + let spread = |force: f64| { + let img = run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("gridPosition", Value::Vec2([40.0, 30.0])), + ("gridWidth", num(6.0)), + ("gridHeight", num(6.0)), + ("particlesAcross", num(3.0)), + ("particlesDown", num(3.0)), + ("gravityForce", num(0.0)), + ("repelForce", num(force)), + ("repelForceRadius", num(20.0)), + ], + 0.5, + ); + let (mut x0, mut x1) = (80, 0); + for y in 0..60 { + for x in 0..80 { + if img.get(x, y)[3] > 0.3 { + x0 = x0.min(x); + x1 = x1.max(x); + } + } + } + x1 - x0 + }; + assert!(spread(0.2) > spread(0.0) + 4, "{} vs {}", spread(0.2), spread(0.0)); + } + + #[test] + fn property_mapper_and_layer_map_read_layers() { + // Without a host, the layer-based features are inert (deterministic output). + let a = run(&[("mapRedTo", Value::Enum(6)), ("redMin", num(-50.0)), ("redMax", num(50.0))], 0.7); + let b = run(&[("mapRedTo", Value::Enum(6)), ("redMin", num(-50.0)), ("redMax", num(50.0))], 0.7); + assert_eq!(a.data, b.data); + let st = Pg { + cannon: false, + pos: [0.0; 2], + barrel_angle: 0.0, + barrel_radius: 0.0, + rate: 0.0, + dir_spread: 0.0, + vel: 0.0, + vel_spread: 0.0, + color: [1.0; 4], + radius: 2.0, + gravity: [0.0; 2], + grav_spread: 0.0, + repel: 0.0, + repel_radius: 0.0, + wall: None, + mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), k: [1.0; 2], o: [0.0; 2] }), + map_to: [(6, 0.0, 120.0), (0, 0.0, 1.0), (4, 0.0, 2.0)], + seed: 1, + bounds: [-1e6, -1e6, 1e6, 1e6], + }; + let mut s = PgState::default(); + s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); + st.step(&mut s); + // Red 1 → X velocity 120 px/s; blue 0.5 → scale 1 (radius 2). + assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); + assert!((s.parts[0].r - 2.0).abs() < 1e-3); + } + + #[test] + fn cc_hair_grows_from_opaque_pixels() { + let mut img = Image::new(60, 60); + for y in 20..40 { + for x in 20..40 { + img.set(x, y, [0.8, 0.6, 0.4, 1.0]); + } + } + let out = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 0.0, EffectEnv::default()); + // Hair reaches beyond the square (above it: hair leans up, then droops). + let outside: f32 = (0..60).flat_map(|x| (0..20).map(move |y| (x, y))).map(|(x, y)| out.img.get(x, y)[3]).sum(); + assert!(outside > 1.0, "{outside}"); + // Nothing grows from transparent areas far away. + assert_eq!(out.img.get(2, 58)[3], 0.0); + let again = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 3.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, img.data); + } +} From 9a11c577f3617740876153880a3c9a86f5e6deba Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:28:37 -0400 Subject: [PATCH 07/10] M9.11: 3D Channel effects on auxiliary channels Aux-channel model (effectcraft_raster::channels3d: depth, object/material ID, normals, UV, coverage, Cryptomatte ranks + manifests, MurmurHash3); EffectHost::aux; multi-layer OpenEXR channels via the exr crate (FootageSource::aux); the compositor's depth / layer-ID / Cryptomatte pass (Renderer::comp_aux) so the effects work on precomps of 3D comps. 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + Cargo.toml | 1 + crates/effects/src/channel3d.rs | 681 +++++++++++++++++++++++++++ crates/effects/src/lib.rs | 11 +- crates/media/Cargo.toml | 2 + crates/media/src/exr_channels.rs | 152 ++++++ crates/media/src/lib.rs | 1 + crates/media/src/pool.rs | 12 + crates/raster/src/channels3d.rs | 206 ++++++++ crates/raster/src/lib.rs | 2 + crates/render/src/lib.rs | 38 ++ crates/render/src/tests_aux.rs | 98 ++++ crates/render/src/three_d/compose.rs | 179 +++++++ 13 files changed, 1385 insertions(+), 1 deletion(-) create mode 100644 crates/effects/src/channel3d.rs create mode 100644 crates/media/src/exr_channels.rs create mode 100644 crates/raster/src/channels3d.rs create mode 100644 crates/render/src/tests_aux.rs diff --git a/Cargo.lock b/Cargo.lock index 46f34e63..0394589c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1327,6 +1327,7 @@ dependencies = [ "effectcraft-raster", "effectcraft-render", "effectcraft-time", + "exr", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", @@ -1335,6 +1336,7 @@ dependencies = [ "image", "log", "rayon", + "serde_json", "thiserror 2.0.21", ] @@ -1768,6 +1770,7 @@ dependencies = [ "miniz_oxide 0.8.9", "num-complex", "pulp", + "rayon-core", "smallvec", "zune-inflate", ] diff --git a/Cargo.toml b/Cargo.toml index a1941cf4..61dd6d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ kurbo = { version = "0.13", features = ["serde"] } tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] } skrifa = { version = "0.44", default-features = false, features = ["std"] } harfrust = { version = "0.12", default-features = false, features = ["std"] } +exr = "1.74" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs new file mode 100644 index 00000000..5fd4d715 --- /dev/null +++ b/crates/effects/src/channel3d.rs @@ -0,0 +1,681 @@ +//! 3D Channel effects: 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, +//! Fog 3D, ID Matte and IDentifier. +//! +//! They read the layer's auxiliary channels ([`AuxChannels`], via +//! [`crate::EffectHost::aux`]): multi-layer OpenEXR footage (depth, object/material IDs, +//! Cryptomatte layers, any named channel) or the compositor's depth / layer-ID / Cryptomatte +//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through +//! (EXtractoR still maps the layer's own R, G, B and A). +//! +//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer +//! pixel's layer position, so render scale and layer padding are respected. + +use std::sync::Arc; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash}; +use effectcraft_raster::{AuxChannels, Image, Px, gaussian_blur}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, hash1, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "3D Channel", params, render, gpu: false, float: true } +} + +fn aux(ctx: &EffectCtx) -> Option> { + ctx.env.host?.aux() +} + +/// Aux index for each buffer pixel (None outside the aux image). +fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inv = 1.0 / b.scale.max(1e-9); + (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) + }) + .collect() +} + +/// A channel sampled into the buffer grid (`fill` outside). +fn plane_of(b: &Buf, idx: &[Option], ch: &[f32], fill: f32) -> Plane { + Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() } +} + +/// Distinct colour for an ID (hash → hue). +fn id_color(id: f32) -> [f32; 3] { + let h = hash1(id.to_bits(), 0x1d, 0x5eed); + let (r, g, b) = effectcraft_color::hsl_to_rgb(h, 0.75, 0.5); + if id == 0.0 { [0.0; 3] } else { [r, g, b] } +} + +// ---------------------------------------------------------------- 3D Channel Extract + +pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", "Surface Normals", "Coverage", "Background RGB", "Unclamped RGB", "Material ID"]; + +fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let idx = index_map(&b, &a); + let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32); + let lv = |v: f32| ((v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum()).clamp(0.0, 1.0); + let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - ((v - wp) / (bp - wp).max(1e-9)).clamp(0.0, 1.0) }; + let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) }; + let img: Option = match ctx.params.e("channel") { + 0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))), + 1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), + 7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), + 4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), + 2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]]) + .or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?])) + .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])), + 3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]]) + .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])), + 5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]]) + .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])), + _ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])), + }; + if let Some(i) = img { + b.img = i; + } + b +} + +fn map_grey(b: &Buf, idx: &[Option], f: impl Fn(usize) -> f32 + Sync) -> Image { + map_rgb(b, idx, |i| { + let v = f(i); + [v, v, v] + }) +} + +fn map_rgb(b: &Buf, idx: &[Option], f: impl Fn(usize) -> [f32; 3] + Sync) -> Image { + let mut img = Image::new(b.img.width, b.img.height); + img.data.par_iter_mut().zip(idx.par_iter()).for_each(|(p, i)| { + if let Some(i) = i { + let c = f(*i); + *p = [c[0], c[1], c[2], 1.0]; + } + }); + img +} + +// ---------------------------------------------------------------- Depth Matte + +fn depth_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let Some(z) = a.depth() else { return b }; + let idx = index_map(&b, &a); + let depth = ctx.params.f("depth") as f32; + let feather = ctx.params.f("feather").max(0.0) as f32; + let invert = ctx.params.b("invert"); + // Keeps what lies at or beyond Depth (Invert keeps what is in front), with a soft ramp of + // `feather` depth units centred on the threshold. + let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH); + b.img.data.par_iter_mut().zip(zp.data.par_iter()).for_each(|(px, &zv)| { + let k = if feather > 0.0 { + ((zv - depth) / feather + 0.5).clamp(0.0, 1.0) + } else if zv >= depth { + 1.0 + } else { + 0.0 + }; + let k = if invert { 1.0 - k } else { k }; + *px = px.map(|v| v * k); + }); + b +} + +// ---------------------------------------------------------------- Depth of Field + +/// Blur radius (pixels) for depth `z`. +pub fn dof_radius(z: f32, focal: f32, thickness: f32, max_r: f32, bias: f32) -> f32 { + let d = ((z - focal).abs() - thickness * 0.5).max(0.0); + let range = focal.abs().max(1.0); + let n = (d / range).min(1.0); + // Bias 50 = linear; higher bias brings blur in sooner. + let gamma = 2f32.powf((50.0 - bias) / 25.0); + max_r * n.powf(gamma) +} + +fn depth_of_field(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let Some(z) = a.depth() else { return b }; + let max_r = (ctx.params.f("maximumRadius").max(0.0) * b.scale) as f32; + if max_r <= 0.0 { + return b; + } + let focal = ctx.params.f("focalPlane") as f32; + let thick = ctx.params.f("focalPlaneThickness").max(0.0) as f32; + let bias = ctx.params.f("focalBias") as f32; + let idx = index_map(&b, &a); + let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH); + // A stack of blurs at a few radii, interpolated per pixel by its circle of confusion. + const LEVELS: usize = 5; + let stack: Vec = (0..LEVELS) + .into_par_iter() + .map(|k| { + let r = max_r * k as f32 / (LEVELS - 1) as f32; + if r < 0.3 { b.img.clone() } else { gaussian_blur(&b.img, (r / 2.0) as f64, (r / 2.0) as f64, true) } + }) + .collect(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let r = dof_radius(zp.data[i], focal, thick, max_r, bias); + let f = (r / max_r * (LEVELS - 1) as f32).clamp(0.0, (LEVELS - 1) as f32); + let k0 = (f.floor() as usize).min(LEVELS - 2); + let t = f - k0 as f32; + let (a0, a1) = (stack[k0].data[i], stack[k0 + 1].data[i]); + *px = [0, 1, 2, 3].map(|c| a0[c] + (a1[c] - a0[c]) * t); + }); + b +} + +// ---------------------------------------------------------------- Fog 3D + +fn fog_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let Some(z) = a.depth() else { return b }; + let pr = ctx.params; + let fc = pr.color("fogColor"); + let (start, end) = (pr.f("fogStartDepth") as f32, pr.f("fogEndDepth") as f32); + let opacity = (pr.f("fogOpacity") / 100.0) as f32; + let density = (pr.f("scatteringDensity") / 100.0) as f32; + let foggy_bg = pr.b("foggyBackground"); + let contrib = (pr.f("layerContribution") / 100.0) as f32; + let grad = if contrib > 0.0 { ctx.layer_param("gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)) } else { None }; + let idx = index_map(&b, &a); + let zp = plane_of(&b, &idx, z, BACKGROUND_DEPTH); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let zv = zp.data[i]; + let bg = zv >= BACKGROUND_DEPTH * 0.5; + let mut f = if bg { + if foggy_bg { 1.0 } else { 0.0 } + } else { + let t = ((zv - start) / (end - start).abs().max(1e-6)).clamp(0.0, 1.0); + // Denser scattering thickens the fog sooner. + 1.0 - (1.0 - t).powf(1.0 + density * 3.0) + }; + if let Some(g) = &grad { + let (gc, ga) = unpremul(g.data[i]); + let l = effectcraft_color::luminance(gc[0], gc[1], gc[2]) * ga; + f *= 1.0 - contrib + contrib * l; + } + let f = (f * opacity).clamp(0.0, 1.0); + let (c, al) = unpremul(*px); + let al2 = if bg && foggy_bg { al + (1.0 - al) * f } else { al }; + let out = [0, 1, 2].map(|k| c[k] + (fc[k] - c[k]) * f); + *px = premul(out, al2); + }); + b +} + +// ---------------------------------------------------------------- ID Matte / IDentifier + +fn id_channel(a: &AuxChannels, which: u32) -> Option<&[f32]> { + if which == 1 { a.material_id() } else { a.object_id() } +} + +fn id_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let Some(ids) = id_channel(&a, ctx.params.e("auxChannel")) else { return b }; + let idx = index_map(&b, &a); + let sel = ctx.params.f("idSelection").round() as f32; + let cov = if ctx.params.b("useCoverage") { a.coverage() } else { None }; + let mut m = Plane { + w: b.img.width as usize, + h: b.img.height as usize, + data: idx.par_iter().map(|i| i.map_or(0.0, |i| if ids[i].round() == sel { cov.map_or(1.0, |c| c[i].clamp(0.0, 1.0)) } else { 0.0 })).collect(), + }; + let feather = ctx.params.f("feather").max(0.0) * b.scale; + if feather > 0.0 { + m = gauss_plane(&m, feather * 0.5, feather * 0.5); + } + let invert = ctx.params.b("invert"); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| { + let k = if invert { 1.0 - k } else { k }; + *px = px.map(|v| v * k); + }); + b +} + +fn identifier(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let Some(ids) = id_channel(&a, ctx.params.e("channelType")) else { return b }; + let idx = index_map(&b, &a); + let sel = ctx.params.f("id").round() as f32; + let display = ctx.params.e("display"); + let max_id = ids.iter().cloned().fold(1.0f32, f32::max); + let src = b.img.clone(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let Some(k) = idx[i] else { + if display != 2 { + *px = [0.0; 4]; + } + return; + }; + let id = ids[k].round(); + let hit = id == sel; + *px = match display { + // Colors: every ID in its own colour, the selected one highlighted white. + 0 => { + let c = if hit && sel != 0.0 { [1.0; 3] } else { id_color(id) }; + [c[0], c[1], c[2], 1.0] + } + 1 => { + let v = if hit { 1.0 } else { 0.0 }; + [v, v, v, 1.0] + } + 2 => { + let s = src.data[i]; + if hit { s } else { [0.0; 4] } + } + _ => { + let v = id / max_id; + [v, v, v, 1.0] + } + }; + }); + b +} + +// ---------------------------------------------------------------- Cryptomatte + +/// Does `name` match a selection pattern (`*` wildcards, case-sensitive like the spec)? +fn wild(pat: &str, name: &str) -> bool { + match pat.split_once('*') { + None => pat == name, + Some((pre, rest)) => { + let Some(tail) = name.strip_prefix(pre) else { return false }; + if rest.is_empty() { + return true; + } + (0..=tail.len()).filter(|&k| tail.is_char_boundary(k)).any(|k| wild(rest, &tail[k..])) + } + } +} + +/// Parse a selection: comma-separated names (optionally quoted) or `` hashes / wildcards. +pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { + let mut out = vec![]; + for tok in sel.split(',') { + let t = tok.trim().trim_matches('"').trim_matches('\''); + if t.is_empty() { + continue; + } + if t.contains('*') { + if let Some(m) = manifest { + out.extend(m.iter().filter(|(n, _)| wild(t, n)).map(|(_, h)| *h)); + } + continue; + } + if let Some(hex) = t.strip_prefix("<").and_then(|r| r.strip_suffix(">")) + && let Ok(h) = u32::from_str_radix(hex, 16) + { + out.push(h); + continue; + } + out.push(manifest.and_then(|m| m.iter().find(|(n, _)| n == t).map(|(_, h)| *h)).unwrap_or_else(|| crypto_hash(t))); + } + out +} + +pub const CRYPTO_LAYERS: [&str; 3] = ["CryptoObject", "CryptoMaterial", "CryptoAsset"]; + +fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(a) = aux(ctx) else { return b }; + let layers = a.crypto_layers(); + if layers.is_empty() { + return b; + } + let want = CRYPTO_LAYERS[(ctx.params.e("layer") as usize).min(2)]; + let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone(); + let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice()); + let sel = selection_hashes(ctx.params.s("selection"), manifest); + let idx = index_map(&b, &a); + let display = ctx.params.e("display"); + let matte_only = ctx.params.b("matteOnly"); + let src = b.img.clone(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let ranks = idx[i].map(|k| a.crypto_ranks(&layer, k)).unwrap_or_default(); + 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); + if matte_only { + *px = [m, m, m, m]; + return; + } + *px = match display { + // Colors: coverage-weighted ID colours (the spec's preview), selection brighter. + 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 c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); + [c[0], c[1], c[2], 1.0] + } + // Matte: the layer keyed by the selection. + 1 => src.data[i].map(|v| v * m), + // Preview: the layer with the selection tinted. + _ => { + let s = src.data[i]; + [s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]] + } + }; + }); + b +} + +// ---------------------------------------------------------------- EXtractoR + +fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { + let a = aux(ctx); + let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")]; + let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32); + let unmult = ctx.params.b("unMult"); + let clip = ctx.params.b("clip"); + let idx = a.as_ref().map(|a| index_map(&b, a)); + // Each output channel: an aux channel by name, or the layer's own R/G/B/A. + let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() { + "R" | "RED" => Some(0), + "G" | "GREEN" => Some(1), + "B" | "BLUE" => Some(2), + "A" | "ALPHA" => Some(3), + "" => None, + _ => None, + }; + let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect(); + if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) { + return b; + } + let src = b.img.clone(); + let lv = |v: f32| { + let o = (v - bp) / (wp - bp).abs().max(1e-9) * (wp - bp).signum(); + if clip { o.clamp(0.0, 1.0) } else { o } + }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (sc, sa) = unpremul(src.data[i]); + let straight = [sc[0], sc[1], sc[2], sa]; + let mut o: Px = [0.0; 4]; + for k in 0..4 { + o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) { + (Some(ch), Some(j)) => ch[j], + (Some(_), None) => 0.0, + (None, _) => match own(names[k]) { + Some(c) => straight[c], + None => { + if k == 3 { + 1.0 + } else { + 0.0 + } + } + }, + }; + if k < 3 { + o[k] = lv(o[k]); + } + } + let al = o[3].clamp(0.0, 1.0); + let c = if unmult && al > 1e-6 { [o[0] / al, o[1] / al, o[2] / al] } else { [o[0], o[1], o[2]] }; + *px = premul(c, al); + }); + b +} + +pub fn specs() -> Vec { + let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); + vec![ + spec( + "ec.3d.channelextract", + "3D Channel Extract", + vec![ + p("channel", "3D Channel", Value::Enum(0), popup(&EXTRACT_CHANNELS)), + p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)), + p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)), + ], + channel_extract, + ), + spec( + "ec.3d.cryptomatte", + "Cryptomatte", + vec![ + p("layer", "Layer", Value::Enum(0), popup(&CRYPTO_LAYERS)), + text("selection", "Selection", ""), + p("display", "Display", Value::Enum(2), popup(&["Colors", "Matte", "Preview"])), + p("matteOnly", "Matte Only", Value::Bool(false), ParamUi::Checkbox), + ], + cryptomatte, + ), + spec( + "ec.3d.depthmatte", + "Depth Matte", + vec![ + p("depth", "Depth", num(0.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)), + p("feather", "Feather", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + ], + depth_matte, + ), + spec( + "ec.3d.depthoffield", + "Depth of Field", + vec![ + p("focalPlane", "Focal Plane", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)), + p("maximumRadius", "Maximum Radius", num(0.0), slider(0.0, 500.0, 0.0, 50.0, 2)), + p("focalPlaneThickness", "Focal Plane Thickness", num(0.0), slider(0.0, 1.0e6, 0.0, 1000.0, 2)), + p("focalBias", "Focal Bias", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 2)), + ], + depth_of_field, + ), + spec( + "ec.3d.extractor", + "EXtractoR", + vec![ + text("red", "Red", "R"), + text("green", "Green", "G"), + text("blue", "Blue", "B"), + text("alpha", "Alpha", "A"), + p("blackPoint", "Black Point", num(0.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)), + p("whitePoint", "White Point", num(1.0), slider(-1.0e6, 1.0e6, -1.0, 2.0, 3)), + p("unMult", "UnMult", Value::Bool(false), ParamUi::Checkbox), + p("clip", "Clip", Value::Bool(false), ParamUi::Checkbox), + ], + extractor, + ), + spec( + "ec.3d.fog3d", + "Fog 3D", + vec![ + p("fogColor", "Fog Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("fogStartDepth", "Fog Start Depth", num(0.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)), + p("fogEndDepth", "Fog End Depth", num(3000.0), slider(-1.0e6, 1.0e6, 0.0, 10000.0, 2)), + p("fogOpacity", "Fog Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("scatteringDensity", "Scattering Density", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("foggyBackground", "Foggy Background", Value::Bool(true), ParamUi::Checkbox), + p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("layerContribution", "Layer Contribution", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + fog_3d, + ), + spec( + "ec.3d.idmatte", + "ID Matte", + vec![ + p("auxChannel", "Aux. Channel", Value::Enum(0), popup(&["Object ID", "Material ID"])), + p("idSelection", "ID Selection", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)), + p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("useCoverage", "Use Coverage", Value::Bool(false), ParamUi::Checkbox), + ], + id_matte, + ), + spec( + "ec.3d.identifier", + "IDentifier", + vec![ + p("channelType", "Channel Type", Value::Enum(0), popup(&["Object ID", "Material ID"])), + p("display", "Display", Value::Enum(0), popup(&["Colors", "Luma Matte", "Alpha Matte", "Raw"])), + p("id", "ID", num(0.0), slider(0.0, 65535.0, 0.0, 100.0, 0)), + ], + identifier, + ), + ] +} + +#[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); + } + + #[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)); + } + + #[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")]); + } + + #[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]); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 2f6a463e..c4666b94 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -11,6 +11,7 @@ mod blur2; mod blur3; mod channel; mod channel2; +mod channel3d; mod color2; mod color3; mod color_fx; @@ -57,7 +58,7 @@ pub use color2::Curve; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; -pub use effectcraft_raster::Image; +pub use effectcraft_raster::{AuxChannels, Image}; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -238,6 +239,13 @@ pub trait EffectHost: Sync { fn layer_at(&self, _id: u64, _comp_time: f64, _masks_and_effects: bool) -> Option { None } + /// Auxiliary 3D channels (depth, object / material IDs, normals, Cryptomatte, any named + /// EXR channel) of the effect's own layer source at the current time, in layer space (see + /// [`AuxChannels`]). `None` when the source has none (the 3D Channel effects then pass the + /// layer through). + fn aux(&self) -> Option> { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). @@ -366,6 +374,7 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(keylight::specs()); v.extend(mocha_shape::specs()); v.extend(sim3::specs()); + v.extend(channel3d::specs()); v.extend(time_fx::specs()); v.extend(audio_fx::specs()); v.extend(paint::specs()); diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 89992569..9466a6cd 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -19,7 +19,9 @@ filmcraft-media = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } +exr = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } rayon = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs new file mode 100644 index 00000000..ba0034c3 --- /dev/null +++ b/crates/media/src/exr_channels.rs @@ -0,0 +1,152 @@ +//! Auxiliary channels from multi-layer OpenEXR files (depth, object / material IDs, normals, +//! Cryptomatte, …) for the 3D Channel effects, read with the pure-Rust `exr` crate. +//! +//! Every channel of every layer becomes a plane named `layer.channel` (or just `channel` for +//! the unnamed layer), placed in the display window (pixels outside a layer's data window are +//! 0, or the background depth for depth channels). Cryptomatte manifests come from the +//! `cryptomatte//name` and `cryptomatte//manifest` header attributes (the published +//! Cryptomatte metadata convention: the manifest is a JSON object of name → hex hash). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_raster::AuxChannels; +use effectcraft_raster::channels3d::BACKGROUND_DEPTH; + +fn is_depth(name: &str) -> bool { + let last = name.rsplit('.').next().unwrap_or(name); + last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") +} + +/// Parse a Cryptomatte manifest (`{"name": "hexhash", …}`). +pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { + let Ok(serde_json::Value::Object(m)) = serde_json::from_str::(json) else { return vec![] }; + let mut v: Vec<(String, u32)> = m.iter().filter_map(|(k, h)| Some((k.clone(), u32::from_str_radix(h.as_str()?.trim(), 16).ok()?))).collect(); + v.sort(); + v +} + +/// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR. +pub fn read_exr_channels(bytes: &[u8]) -> Option { + use exr::prelude::*; + let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(std::io::Cursor::new(bytes)).ok()?; + let dw = img.attributes.display_window; + let (w, h) = (dw.size.0, dw.size.1); + if w == 0 || h == 0 { + return None; + } + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + let mut crypto: HashMap, Option)> = HashMap::new(); + let mut collect_meta = |other: &HashMap| { + for (k, v) in other { + let key = k.to_string(); + let Some(rest) = key.strip_prefix("cryptomatte/") else { continue }; + let Some((id, field)) = rest.split_once('/') else { continue }; + let AttributeValue::Text(t) = v else { continue }; + let e = crypto.entry(id.to_string()).or_default(); + match field { + "name" => e.0 = Some(t.to_string()), + "manifest" => e.1 = Some(t.to_string()), + _ => {} + } + } + }; + collect_meta(&img.attributes.other); + for layer in img.layer_data.iter() { + collect_meta(&layer.attributes.other); + let prefix = layer.attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty()); + let (lw, lh) = (layer.size.0, layer.size.1); + let pos = layer.attributes.layer_position; + let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64); + for ch in layer.channel_data.list.iter() { + let name = match &prefix { + Some(p) => format!("{p}.{}", ch.name), + None => ch.name.to_string(), + }; + let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 }; + let mut plane = vec![fill; w * h]; + let vals: Vec = ch.sample_data.values_as_f32().collect(); + if vals.len() < lw * lh { + continue; + } + for y in 0..lh { + let ty = y as i64 + oy; + if ty < 0 || ty >= h as i64 { + continue; + } + for x in 0..lw { + let tx = x as i64 + ox; + if tx < 0 || tx >= w as i64 { + continue; + } + plane[ty as usize * w + tx as usize] = vals[y * lw + x]; + } + } + aux.channels.push((name, plane)); + } + } + for (_, (name, manifest)) in crypto { + if let (Some(n), Some(m)) = (name, manifest) { + aux.manifests.push((n, parse_manifest(&m))); + } + } + aux.manifests.sort(); + Some(aux) +} + +type Cache = Mutex>)>>; + +/// Read (and cache, a few files) the channels of the EXR at `path` via `read`. +pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option>) -> Option> { + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(Vec::new())); + if let Some(v) = cache.lock().ok().and_then(|c| c.iter().find(|(p, _)| p == path).map(|(_, v)| v.clone())) { + return v; + } + let v = read().and_then(|b| read_exr_channels(&b)).map(Arc::new); + if let Ok(mut c) = cache.lock() { + if c.len() >= 4 { + c.remove(0); + } + c.push((path.to_string(), v.clone())); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_layered_channels_and_cryptomatte_metadata() { + use exr::prelude::*; + let (w, h) = (4usize, 2usize); + let z: Vec = (0..w * h).map(|i| i as f32 * 10.0).collect(); + let id: Vec = (0..w * h).map(|i| (i % 2) as f32 + 1.0).collect(); + let r: Vec = vec![0.5; w * h]; + let channels = AnyChannels::sort( + vec![ + AnyChannel::new("R", FlatSamples::F32(r)), + AnyChannel::new("Z", FlatSamples::F32(z.clone())), + AnyChannel::new("ObjectID", FlatSamples::F32(id.clone())), + ] + .into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::named("depth"), Encoding::FAST_LOSSLESS, channels); + layer.attributes.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoObject"))); + layer + .attributes + .other + .insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"bunny":"13851a76","floor":"0a1b2c3d"}"#))); + let image = Image::from_layer(layer); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let aux = read_exr_channels(bytes.get_ref()).unwrap(); + assert_eq!((aux.width, aux.height), (4, 2)); + assert_eq!(aux.get("depth.Z").unwrap(), z.as_slice()); + assert_eq!(aux.depth().unwrap(), z.as_slice()); + assert_eq!(aux.object_id().unwrap(), id.as_slice()); + assert_eq!(aux.manifests, vec![("CryptoObject".to_string(), vec![("bunny".to_string(), 0x1385_1a76), ("floor".to_string(), 0x0a1b_2c3d)])]); + assert!(read_exr_channels(b"not an exr").is_none()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 29546f7e..aa4a8f49 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -13,6 +13,7 @@ //! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR). mod convert; +pub mod exr_channels; mod pool; mod probe; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index b4cc51ef..caa0c4f9 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -470,6 +470,18 @@ impl FootageSource for MediaPool { } } + fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { + if footage.missing || !footage.has_video { + return None; + } + let loc = Inner::locate(footage, t); + if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") { + return None; + } + let path = loc.key.path.clone(); + crate::exr_channels::cached(&path, || self.inner.read(&path).ok()) + } + fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { if !footage.has_audio || footage.missing { return None; diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs new file mode 100644 index 00000000..0402349b --- /dev/null +++ b/crates/raster/src/channels3d.rs @@ -0,0 +1,206 @@ +//! Auxiliary (3D) channels that travel with a layer's pixels: depth, object / material IDs, +//! surface normals, texture UVs, coverage and Cryptomatte ID/coverage pairs. +//! +//! They come from multi-layer OpenEXR footage (any channel by its full `layer.channel` name) +//! or from the compositor's own render passes for precomps of 3D comps. The 3D Channel effects +//! read them through `EffectHost::aux`. +//! +//! Conventions (shared by the EXR reader and the render pass): +//! +//! * channel planes are row-major `width × height` `f32`s covering the layer's source rectangle, +//! with `scale` aux pixels per layer pixel; +//! * depth is camera-space distance in pixels (`Z`), background `BACKGROUND_DEPTH`; +//! * `ObjectID` / `MaterialID` are small integers (0 = background); +//! * Cryptomatte layers follow the published Cryptomatte specification (Psyop, 2015): a layer +//! named `CryptoObject` stores rank pairs in `CryptoObject00.R/G/B/A`, `CryptoObject01.R/…` +//! (ID float, coverage, ID float, coverage, …), IDs are MurmurHash3 (x86, 32-bit, seed 0) of +//! the object name reinterpreted as a float with the exponent nudged off 0 and 255, and the +//! manifest maps names to hashes. + +/// Depth written where nothing was rendered. +pub const BACKGROUND_DEPTH: f32 = 1.0e10; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AuxChannels { + pub width: u32, + pub height: u32, + /// Aux pixels per layer pixel. + pub scale: f64, + /// Named planes (`Z`, `ObjectID`, `CryptoObject00.R`, …). + pub channels: Vec<(String, Vec)>, + /// Cryptomatte manifests: (layer name, [(object name, hash)]). + pub manifests: Vec<(String, Vec<(String, u32)>)>, +} + +impl AuxChannels { + pub fn new(width: u32, height: u32, scale: f64) -> AuxChannels { + AuxChannels { width, height, scale, channels: vec![], manifests: vec![] } + } + + /// A channel by exact name (case-insensitive). + pub fn get(&self, name: &str) -> Option<&[f32]> { + self.channels.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_slice()) + } + + /// The first channel present among `names` (also matching `*.name` suffixes, so `Z` finds + /// `depth.Z`). + pub fn find(&self, names: &[&str]) -> Option<&[f32]> { + for n in names { + if let Some(v) = self.get(n) { + return Some(v); + } + } + for n in names { + let suffix = format!(".{}", n.to_ascii_lowercase()); + if let Some((_, v)) = self.channels.iter().find(|(c, _)| c.to_ascii_lowercase().ends_with(&suffix)) { + return Some(v); + } + } + None + } + + pub fn names(&self) -> Vec<&str> { + self.channels.iter().map(|(n, _)| n.as_str()).collect() + } + + pub fn depth(&self) -> Option<&[f32]> { + self.find(&["Z", "depth", "Depth", "zDepth", "Z-Depth"]) + } + pub fn object_id(&self) -> Option<&[f32]> { + self.find(&["ObjectID", "object_id", "objectId", "id"]) + } + pub fn material_id(&self) -> Option<&[f32]> { + self.find(&["MaterialID", "material_id", "materialId"]) + } + pub fn coverage(&self) -> Option<&[f32]> { + self.find(&["Coverage", "coverage"]) + } + + /// Cryptomatte layer names present (prefixes with a `00.R` channel). + pub fn crypto_layers(&self) -> Vec { + let mut v: Vec = + self.channels.iter().filter_map(|(n, _)| n.strip_suffix("00.R").or_else(|| n.strip_suffix("00.r")).map(str::to_string)).collect(); + v.sort(); + v.dedup(); + v + } + + /// Rank pairs (id, coverage) of Cryptomatte layer `layer` at aux pixel index `i`. + pub fn crypto_ranks(&self, layer: &str, i: usize) -> Vec<(f32, f32)> { + let mut out = vec![]; + for k in 0.. { + let (r, g, b, a) = ( + self.get(&format!("{layer}{k:02}.R")), + self.get(&format!("{layer}{k:02}.G")), + self.get(&format!("{layer}{k:02}.B")), + self.get(&format!("{layer}{k:02}.A")), + ); + let (Some(r), Some(g)) = (r, g) else { break }; + out.push((r[i], g[i])); + if let (Some(b), Some(a)) = (b, a) { + out.push((b[i], a[i])); + } + } + out + } + + /// Aux pixel index of a layer-space point (nearest; `None` outside). + #[inline] + pub fn index_at(&self, lx: f64, ly: f64) -> Option { + let x = (lx * self.scale).floor(); + let y = (ly * self.scale).floor(); + if x < 0.0 || y < 0.0 || x >= self.width as f64 || y >= self.height as f64 { + return None; + } + Some(y as usize * self.width as usize + x as usize) + } + + /// Manifest entry name for a hash in Cryptomatte layer `layer`. + pub fn manifest_name(&self, layer: &str, hash: u32) -> Option<&str> { + self.manifests.iter().find(|(l, _)| l == layer)?.1.iter().find(|(_, h)| *h == hash).map(|(n, _)| n.as_str()) + } +} + +/// MurmurHash3 x86 32-bit (public-domain algorithm by Austin Appleby). +pub fn murmur3_32(data: &[u8], seed: u32) -> u32 { + const C1: u32 = 0xcc9e_2d51; + const C2: u32 = 0x1b87_3593; + let mut h = seed; + let chunks = data.chunks_exact(4); + let tail = chunks.remainder(); + for c in chunks { + let mut k = u32::from_le_bytes([c[0], c[1], c[2], c[3]]); + k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); + h ^= k; + h = h.rotate_left(13).wrapping_mul(5).wrapping_add(0xe654_6b64); + } + let mut k = 0u32; + for (i, b) in tail.iter().enumerate() { + k ^= (*b as u32) << (8 * i); + } + if !tail.is_empty() { + k = k.wrapping_mul(C1).rotate_left(15).wrapping_mul(C2); + h ^= k; + } + h ^= data.len() as u32; + h ^= h >> 16; + h = h.wrapping_mul(0x85eb_ca6b); + h ^= h >> 13; + h = h.wrapping_mul(0xc2b2_ae35); + h ^= h >> 16; + h +} + +/// Cryptomatte hash of a name (the manifest value). +pub fn crypto_hash(name: &str) -> u32 { + let h = murmur3_32(name.as_bytes(), 0); + // Keep the float finite and normal: flip the low exponent bit when it is all 0s or 1s. + let e = (h >> 23) & 0xff; + if e == 0 || e == 255 { h ^ (1 << 23) } else { h } +} + +/// The ID float stored in Cryptomatte channels for a hash. +pub fn crypto_float(hash: u32) -> f32 { + f32::from_bits(hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn murmur3_reference_values() { + // Published MurmurHash3_x86_32 test vectors. + assert_eq!(murmur3_32(b"", 0), 0); + assert_eq!(murmur3_32(b"", 1), 0x514e_28b7); + assert_eq!(murmur3_32(b"", 0xffff_ffff), 0x81f1_6f39); + assert_eq!(murmur3_32(&[0, 0, 0, 0], 0), 0x2362_f9de); + assert_eq!(murmur3_32(b"a", 0x9747_b28c), 0x7fa0_9ea6); + assert_eq!(murmur3_32(b"aaaa", 0x9747_b28c), 0x5a97_808a); + assert_eq!(murmur3_32(b"Hello, world!", 0x9747_b28c), 0x2488_4cba); + assert_eq!(murmur3_32(b"The quick brown fox jumps over the lazy dog", 0x9747_b28c), 0x2fa8_26cd); + } + + #[test] + fn crypto_floats_are_normal_and_round_trip() { + for n in ["bunny", "set", "ground", "a", ""] { + let h = crypto_hash(n); + let f = crypto_float(h); + assert!(f.is_finite() && (f == 0.0 || f.is_normal() || f.abs() > 0.0), "{n}"); + assert_eq!(f.to_bits(), h); + } + } + + #[test] + fn lookup_helpers() { + let mut a = AuxChannels::new(2, 1, 1.0); + a.channels.push(("depth.Z".into(), vec![5.0, 6.0])); + a.channels.push(("CryptoObject00.R".into(), vec![1.0, 2.0])); + a.channels.push(("CryptoObject00.G".into(), vec![0.5, 1.0])); + assert_eq!(a.depth(), Some(&[5.0, 6.0][..])); + assert_eq!(a.crypto_layers(), vec!["CryptoObject".to_string()]); + assert_eq!(a.crypto_ranks("CryptoObject", 0), vec![(1.0, 0.5)]); + assert_eq!(a.index_at(1.5, 0.2), Some(1)); + assert_eq!(a.index_at(2.5, 0.2), None); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dfe53848..4f244199 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,6 +4,7 @@ //! heavy operation is row-parallel with rayon. pub mod blur; +pub mod channels3d; pub mod flow; pub mod warp; @@ -11,6 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use rayon::prelude::*; pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur}; +pub use channels3d::AuxChannels; pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; pub type Px = [f32; 4]; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bd42d31a..f09f1a6e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,6 +39,12 @@ pub trait FootageSource: Send + Sync { fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { None } + /// Auxiliary 3D channels of `item` at source time `t` (multi-layer OpenEXR: depth, IDs, + /// Cryptomatte, any named channel), with `scale` relative to the file's pixels. `None` when + /// the footage has none. + fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option> { + None + } /// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without /// a cache ignore it. fn set_cache_budget(&self, _bytes: usize) {} @@ -107,6 +113,29 @@ impl EffectHost for FxHost<'_, '_, '_> { sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone()) } + fn aux(&self) -> Option> { + match &self.layer.source { + LayerSource::Footage { item } => { + let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?; + // Aux pixels per layer pixel (the layer is the footage at its nominal size). + if f.width > 0 && a.width > 0 { + let mut a = (*a).clone(); + a.scale = a.width as f64 / f.width as f64; + return Some(Arc::new(a)); + } + Some(a) + } + LayerSource::Comp { item } => { + if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { + return None; + } + self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new) + } + _ => None, + } + } + fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { @@ -1175,6 +1204,13 @@ impl<'a> Renderer<'a> { self.depth } + /// The auxiliary 3D channels of comp `comp_id` at comp time `t` (depth, layer IDs, normals, + /// UVs, Cryptomatte; see `three_d::compose::aux_pass`), at the renderer's output scale. + pub fn comp_aux(&self, comp_id: ItemId, t: Tick) -> Option { + let ctx = self.eval_ctx(comp_id, t)?; + Some(three_d::compose::aux_pass(self, &ctx)) + } + /// Evaluation context of comp `comp_id` at comp time `t`. pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option> { Some(self.ctx(comp_id, self.project.comp(comp_id)?, t)) @@ -1295,6 +1331,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath { #[cfg(test)] mod tests_audio_fx; #[cfg(test)] +mod tests_aux; +#[cfg(test)] mod tests_collapse; #[cfg(test)] mod tests_color; diff --git a/crates/render/src/tests_aux.rs b/crates/render/src/tests_aux.rs new file mode 100644 index 00000000..31bb1881 --- /dev/null +++ b/crates/render/src/tests_aux.rs @@ -0,0 +1,98 @@ +//! Auxiliary 3D channels: the comp's depth / ID / Cryptomatte pass and the 3D Channel effects on +//! a precomp of a 3D comp. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn zoom() -> f64 { + W as f64 * 50.0 / 36.0 +} + +fn solid3(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], pos: [f64; 3]) -> Layer { + let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: 60, height: 60, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, name, LayerSource::Solid { item: sid }, (60, 60), None); + l.name = name.to_string(); + l.switches.three_d = true; + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos); + l +} + +/// Inner 3D comp: "Near" (red, z 0, left) above "Far" (green, z 500, right). Outer comp: the +/// precomp layer, optionally with an effect. +fn scene(effect: Option<(&str, Vec<(&str, Value)>)>) -> (Project, ItemId, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let near = solid3(&mut p, &inner, "Near", [1.0, 0.0, 0.0], [60.0, 50.0, 0.0]); + let far = solid3(&mut p, &inner, "Far", [0.0, 1.0, 0.0], [140.0, 50.0, 500.0]); + p.comp_mut(iid).unwrap().layers.push(near); + p.comp_mut(iid).unwrap().layers.push(far); + let outer = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (W, H), None); + if let Some((id, vals)) = effect { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v.clone(); + } + pre.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.comp_mut(oid).unwrap().layers.push(pre); + (p, iid, oid) +} + +#[test] +fn comp_aux_pass_has_depth_ids_and_cryptomatte() { + let (p, iid, _) = scene(None); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let a = r.comp_aux(iid, Tick::ZERO).unwrap(); + assert_eq!((a.width, a.height), (W, H)); + let at = |name: &str, x: usize, y: usize| a.get(name).unwrap()[y * W as usize + x]; + // Near plane at the camera's zoom distance, the far one 500 px behind it. + assert!((at("Z", 60, 50) as f64 - zoom()).abs() < 1e-2, "{}", at("Z", 60, 50)); + assert!((at("Z", 114, 50) as f64 - (zoom() + 500.0)).abs() < 1e-2, "{}", at("Z", 114, 50)); + assert_eq!(at("Z", 5, 5), BACKGROUND_DEPTH); + assert_eq!((at("ObjectID", 60, 50), at("ObjectID", 114, 50), at("ObjectID", 5, 5)), (1.0, 2.0, 0.0)); + assert_eq!(at("Coverage", 60, 50), 1.0); + // Planes face the camera. + assert!((at("N.Z", 60, 50) + 1.0).abs() < 1e-5); + // Cryptomatte rank 0 = the layer under the pixel, full coverage; manifest lists names. + assert_eq!(at("CryptoObject00.R", 60, 50).to_bits(), crypto_hash("Near")); + assert_eq!(at("CryptoObject00.G", 60, 50), 1.0); + assert_eq!(a.manifest_name("CryptoObject", crypto_hash("Far")), Some("Far")); +} + +#[test] +fn three_d_channel_effects_work_on_a_precomp_of_a_3d_comp() { + let near = (60, 50); + let far = (114, 50); + let alpha = |p: &Project, oid: ItemId, (x, y): (i64, i64)| render_frame(p, oid, Tick::ZERO, 1.0).get(x, y)[3]; + let (p, _, oid) = scene(Some(("ec.3d.idmatte", vec![("idSelection", Value::Scalar(2.0))]))); + assert_eq!(alpha(&p, oid, near), 0.0); + assert!(alpha(&p, oid, far) > 0.99); + let (p, _, oid) = scene(Some(("ec.3d.depthmatte", vec![("depth", Value::Scalar(zoom() + 250.0))]))); + assert_eq!(alpha(&p, oid, near), 0.0); + assert!(alpha(&p, oid, far) > 0.99); + let (p, _, oid) = scene(Some(("ec.3d.cryptomatte", vec![("selection", Value::Str("Near".into())), ("display", Value::Enum(1))]))); + assert!(alpha(&p, oid, near) > 0.99); + assert_eq!(alpha(&p, oid, far), 0.0); + // Half resolution: the pass follows the render scale. + let (p, iid, _) = scene(None); + let r = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }); + let a = r.comp_aux(iid, Tick::ZERO).unwrap(); + assert_eq!((a.width, a.height, a.scale), (W / 2, H / 2, 0.5)); + assert_eq!(a.get("ObjectID").unwrap()[25 * 100 + 30], 1.0); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 1ef7412b..8f8e6ff5 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -583,3 +583,182 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { } out } + +// ------------------------------------------------------------------ auxiliary render pass + +/// One surface seen through an output pixel (aux pass). +#[derive(Clone, Copy)] +struct AuxFrag { + z: f32, + a: f32, + obj: f32, + mat: f32, + n: [f32; 3], + uv: [f32; 2], + obj_hash: u32, + mat_hash: u32, +} + +/// The comp's auxiliary channels at the context time, at output resolution: camera-space depth +/// (`Z`), `ObjectID` (layer number), `MaterialID` (source item id), `Coverage`, camera-space +/// normals (`N.X/Y/Z`), texture coordinates (`UV.U/V`) and Cryptomatte `CryptoObject` (layer +/// names) / `CryptoMaterial` (source names) with two ranks each, plus their manifests. +/// +/// Layers are gathered in stack order (top first); a run of 3D layers contributes its planes +/// sorted near → far at each pixel, a 2D layer its coverage on the plane of the comp. The +/// front-most surface with ≥ 50 % opacity (else the strongest one) provides depth, IDs, normal +/// and UV; Cryptomatte coverage is each surface's visible share (`α · Π(1 − α_front)`). +pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxChannels { + use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash}; + let s = r.opts.scale; + let (w, h) = (((ctx.comp.width as f64 * s).round() as u32).max(1), ((ctx.comp.height as f64 * s).round() as u32).max(1)); + let out = (w, h); + let (wu, n) = (w as usize, w as usize * h as usize); + let cam = camera_for(r, ctx); + let visible: Vec<&Layer> = r.visible_layers(ctx).into_iter().filter(|l| !l.switches.adjustment).collect(); + let number = |l: &Layer| ctx.comp.layers.iter().position(|x| x.id == l.id).map_or(0.0, |i| i as f32 + 1.0); + let source_name = |l: &Layer| l.source.item().and_then(|i| r.project.item(i)).map(|it| it.name.clone()).unwrap_or_else(|| l.name.clone()); + let source_id = |l: &Layer| l.source.item().map_or(0.0, |i| i.0 as f32); + let mut manifest_obj: Vec<(String, u32)> = vec![]; + let mut manifest_mat: Vec<(String, u32)> = vec![]; + for l in &visible { + let (on, mn) = (l.name.clone(), source_name(l)); + if !manifest_obj.iter().any(|(x, _)| *x == on) { + manifest_obj.push((on.clone(), crypto_hash(&on))); + } + if !manifest_mat.iter().any(|(x, _)| *x == mn) { + manifest_mat.push((mn.clone(), crypto_hash(&mn))); + } + } + // Groups in stack order: runs of 3D layers, single 2D layers. + let mut groups: Vec<&[&Layer]> = vec![]; + let mut i = 0; + while i < visible.len() { + let mut j = i + 1; + if visible[i].is_3d() { + while j < visible.len() && visible[j].is_3d() { + j += 1; + } + } + groups.push(&visible[i..j]); + i = j; + } + let mut frags: Vec> = vec![Vec::new(); n]; + let plane_z = cam.depth(vec3(ctx.comp.width as f64 * 0.5, ctx.comp.height as f64 * 0.5, 0.0)) as f32; + for g in groups.iter().rev() { + if g[0].is_3d() { + let items: Vec = g.iter().enumerate().flat_map(|(k, l)| prepare_all(r, ctx, l, k, out, true)).collect(); + let meta: Vec<(f32, f32, u32, u32, [f32; 3], [f64; 2])> = items + .iter() + .map(|it| { + let nrm = it.geos.first().map_or(vec3(0.0, 0.0, -1.0), |gm| cam.view.apply_vec(gm.normal)); + let mut nv = [nrm.x as f32, nrm.y as f32, nrm.z as f32]; + if nv[2] > 0.0 { + nv = nv.map(|v| -v); + } + let (sw, sh) = crate::source_size(r.project, it.layer); + let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; + (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) + }) + .collect(); + frags.par_chunks_mut(wu).enumerate().for_each(|(y, row)| { + let yi = y as i64; + let mut local: Vec = Vec::new(); + for (x, cell) in row.iter_mut().enumerate() { + let xi = x as i64; + local.clear(); + for (it, m) in items.iter().zip(&meta) { + if xi < it.bbox[0] || xi >= it.bbox[2] || yi < it.bbox[1] || yi >= it.bbox[3] { + continue; + } + let Some(gm) = it.geos.first() else { continue }; + let Some((u, v, z)) = unproject(gm, x as f64 + 0.5, y as f64 + 0.5) else { continue }; + let a = it.texel(u, v)[3] * it.opacity; + if a > 1e-4 { + let uv = [(u / m.5[0]) as f32, (v / m.5[1]) as f32]; + local.push(AuxFrag { z: z as f32, a: a.min(1.0), obj: m.0, mat: m.1, n: m.4, uv, obj_hash: m.2, mat_hash: m.3 }); + } + } + local.sort_by(|p, q| p.z.total_cmp(&q.z)); + cell.extend_from_slice(&local); + } + }); + } else { + let l = g[0]; + let mut img = Image::new(w, h); + if let Some(b) = r.layer_buf(ctx, l) { + r.place(ctx, l, &b, &mut img, BlendMode::Normal, ctx.opacity(l) as f32); + } + let (obj, mat, oh, mh) = (number(l), source_id(l), crypto_hash(&l.name), crypto_hash(&source_name(l))); + frags.par_chunks_mut(wu).zip(img.data.par_chunks(wu)).enumerate().for_each(|(y, (row, px))| { + for (x, (cell, p)) in row.iter_mut().zip(px).enumerate() { + if p[3] > 1e-4 { + let uv = [(x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32]; + cell.push(AuxFrag { z: plane_z, a: p[3].min(1.0), obj, mat, n: [0.0, 0.0, -1.0], uv, obj_hash: oh, mat_hash: mh }); + } + } + }); + } + } + // Resolve per pixel. + const NAMES: [&str; 9] = ["Z", "ObjectID", "MaterialID", "Coverage", "N.X", "N.Y", "N.Z", "UV.U", "UV.V"]; + const CRYPTO: usize = 16; + let resolved: Vec<([f32; 9], [f32; CRYPTO])> = frags + .par_iter() + .map(|fs| { + let mut o = [BACKGROUND_DEPTH, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let mut crypto = [0.0f32; CRYPTO]; + if fs.is_empty() { + return (o, crypto); + } + let mut t = 1.0f32; + let mut obj: Vec<(u32, f32)> = vec![]; + let mut mat: Vec<(u32, f32)> = vec![]; + let mut best: Option<(usize, f32)> = None; + let mut front: Option = None; + for (k, f) in fs.iter().enumerate() { + let c = f.a * t; + if front.is_none() && f.a >= 0.5 { + front = Some(k); + } + if best.is_none_or(|(_, bc)| c > bc) { + best = Some((k, c)); + } + for (list, hsh) in [(&mut obj, f.obj_hash), (&mut mat, f.mat_hash)] { + match list.iter_mut().find(|(x, _)| *x == hsh) { + Some(e) => e.1 += c, + None => list.push((hsh, c)), + } + } + t *= 1.0 - f.a; + if t <= 1e-5 { + break; + } + } + let f = fs[front.or(best.map(|b| b.0)).unwrap_or(0)]; + o = [f.z, f.obj, f.mat, 1.0 - t, f.n[0], f.n[1], f.n[2], f.uv[0], f.uv[1]]; + for (base, list) in [(0usize, &mut obj), (8usize, &mut mat)] { + list.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + for (rank, (hsh, c)) in list.iter().take(4).enumerate() { + crypto[base + rank * 2] = crypto_float(*hsh); + crypto[base + rank * 2 + 1] = *c; + } + } + (o, crypto) + }) + .collect(); + let mut aux = effectcraft_raster::AuxChannels::new(w, h, s); + for (k, name) in NAMES.iter().enumerate() { + aux.channels.push((name.to_string(), resolved.iter().map(|r| r.0[k]).collect())); + } + for (base, layer) in [(0usize, "CryptoObject"), (8usize, "CryptoMaterial")] { + for rank_pair in 0..2 { + for (c, ch) in ["R", "G", "B", "A"].iter().enumerate() { + let k = base + rank_pair * 4 + c; + aux.channels.push((format!("{layer}{rank_pair:02}.{ch}"), resolved.iter().map(|r| r.1[k]).collect())); + } + } + } + aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)]; + aux +} From ad697a342d69da5b401944a42d109cabd544c0cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:48:11 -0400 Subject: [PATCH 08/10] =?UTF-8?q?M9.11:=20parity=20doc=20=E2=80=94=20296?= =?UTF-8?q?=20of=20298=20After=20Effects=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index af0c9420..bbe28965 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -15,7 +15,7 @@ rendered, does not count as done. | P1 (professional daily use) | ≈ 38% | | P2 (long tail) | ≈ 17% | | Features done / partial / missing | 18 / 60 / 14 of 92 | -| **Effects** | **257 of 298** After Effects 2026 effects by name (86%); ≈ 70% allowing for simplified implementations | +| **Effects** | **296 of 298** After Effects 2026 effects by name (99%, after M9.11; was 257); the two left are Warp Stabilizer and the 3D Camera Tracker | | Remaining work | ≈ **167 agent-hours** (139 h of features + ≈ 20% for 1:1 polish against After Effects) | | Wall-clock estimate | ≈ **42–50 hours** with five agents working in parallel and one integrating; ≈ 31–35 h for P0 + P1 only | @@ -76,8 +76,8 @@ library groundwork for mask tracking and Warp Stabilizer. What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker, Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache, -the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and -wasm threads. +native macOS menus, and wasm threads. (The missing effect categories, 3D Channel, Immersive Video +and OCIO, landed in M9.11.) ## By area @@ -94,7 +94,7 @@ wasm threads. | Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) | | Web | 60% | 2.5 | threads, browser storage, audio | | 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts | -| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects | +| Effects | ≈ 75% | 6.0 | GPU versions of more effects, Warp Stabilizer and the 3D Camera Tracker (all other After Effects effects exist since M9.11) | | Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves | | Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points | @@ -104,12 +104,24 @@ wasm threads. ## Effects still missing -Immersive Video (all 12), 3D Channel (all 8, including Cryptomatte), Color Stabilizer and the five -OCIO transforms, CC Flo Motion, Liquify, Rolling Shutter Repair, Warp Stabilizer, Compressor, -Distortion and Gate (audio), Camera-Shake Deblur, CC Radial Blur, CC Hair, Particle Playground, -Keylight (our Screen Key stands in), Color Profile Converter and the 3D Camera Tracker. Boris FX +Warp Stabilizer and the 3D Camera Tracker (built on the tracking library by other work). Boris FX Mocha and Cineware are third-party and not counted. +### Added in M9.11 (39 effects) + +| Category | Effects | Notes | +|---|---|---| +| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | +| Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere To Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. | +| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are not read yet. | +| Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. | +| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. | +| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. | +| Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). | +| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. | +| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). | +| Obsolete | mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. | + ## Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). From 78cd313d3cebfbcca543a260cb5ca52840d28c0e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:13:09 -0400 Subject: [PATCH 09/10] M14.4: scripting with an After Effects-style JavaScript object model New crate effectcraft-script (L4): boa runtime with app/Project/CompItem/ Layer/PropertyGroup/Property/TextDocument/MarkerValue/RenderQueue/File object model over engine commands (undoable, undo groups), AE match-name table, file/network security gate. Entry points: script.run command, File > Scripts > Run Script File (.jsx/.js), Window > Script Console, effectcraft-cli script, MCP run_script. Engine: project.delete/duplicate, prop.renameGroup/setGroupEnabled/removeGroup/duplicateGroup/moveGroup. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 17 + Cargo.toml | 1 + apps/effectcraft-cli/src/main.rs | 51 +- apps/effectcraft-cli/tests/cli.rs | 31 + apps/effectcraft-web/src/lib.rs | 5 +- crates/automation/Cargo.toml | 3 + crates/automation/src/tests.rs | 28 + crates/automation/src/tools.rs | 24 + crates/engine/src/commands/file_more.rs | 60 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/project_items.rs | 96 ++ crates/engine/src/commands/prop_groups.rs | 107 ++ crates/engine/src/commands/text_anim.rs | 3 +- crates/engine/src/lib.rs | 18 + crates/engine/src/menus.rs | 1 + crates/engine/src/tests_project_items.rs | 57 + crates/host/Cargo.toml | 3 +- crates/host/src/lib.rs | 38 +- crates/script/Cargo.toml | 27 + crates/script/src/lib.rs | 64 + crates/script/src/matchnames.rs | 443 +++++ crates/script/src/model.rs | 679 ++++++++ crates/script/src/prelude.js | 1689 +++++++++++++++++++ crates/script/src/runtime.rs | 546 ++++++ crates/script/src/tests.rs | 392 +++++ crates/ui-egui/src/dock.rs | 5 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/project.rs | 14 +- crates/ui-egui/src/panels/script_console.rs | 144 ++ crates/ui-egui/src/state.rs | 4 + crates/ui-egui/tests/ui_script_console.rs | 44 + docs/agents.md | 14 + docs/architecture.md | 28 +- docs/parity.md | 4 +- xtask/src/main.rs | 3 + 36 files changed, 4621 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/commands/prop_groups.rs create mode 100644 crates/script/Cargo.toml create mode 100644 crates/script/src/lib.rs create mode 100644 crates/script/src/matchnames.rs create mode 100644 crates/script/src/model.rs create mode 100644 crates/script/src/prelude.js create mode 100644 crates/script/src/runtime.rs create mode 100644 crates/script/src/tests.rs create mode 100644 crates/ui-egui/src/panels/script_console.rs create mode 100644 crates/ui-egui/tests/ui_script_console.rs diff --git a/Cargo.lock b/Cargo.lock index 46f34e63..c1e86483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1145,6 +1145,7 @@ name = "effectcraft-automation" version = "0.1.0" dependencies = [ "effectcraft-engine", + "effectcraft-script", "image", "serde_json", "thiserror 2.0.21", @@ -1287,6 +1288,7 @@ dependencies = [ "effectcraft-expr", "effectcraft-media", "effectcraft-project", + "effectcraft-script", "serde_json", ] @@ -1392,6 +1394,21 @@ dependencies = [ "web-time", ] +[[package]] +name = "effectcraft-script" +version = "0.1.0" +dependencies = [ + "boa_engine", + "effectcraft-engine", + "effectcraft-expr", + "effectcraft-keyframe", + "effectcraft-path", + "effectcraft-project", + "effectcraft-render", + "effectcraft-time", + "serde_json", +] + [[package]] name = "effectcraft-text" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a1941cf4..6c5ac99a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ effectcraft-engine = { path = "crates/engine" } effectcraft-host = { path = "crates/host" } effectcraft-ui-egui = { path = "crates/ui-egui" } effectcraft-automation = { path = "crates/automation" } +effectcraft-script = { path = "crates/script" } # foundation thiserror = "2" diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 90d7ef66..b8222ddc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -15,6 +15,7 @@ //! effectcraft-cli render F.ecproj --queue render the project's Render Queue //! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings //! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half) +//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script //! effectcraft-cli mcp [--bridge PORT] MCP server on stdio //! //! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) @@ -32,7 +33,7 @@ use effectcraft_automation::tools::{self, Reply}; use effectcraft_automation::{Backend, McpServer}; use serde_json::{Value, json}; -const USAGE: &str = "usage: effectcraft-cli [args] [--json] +const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands exec [--params JSON] run one engine command @@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli : id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; @@ -68,6 +72,7 @@ const VALUED: &[&str] = &[ "--out", "--expression", "--depth", + "--eval", // render "--format", "--start", @@ -243,6 +248,49 @@ fn tool(b: &mut Backend, name: &str, a: Value) -> Result { } } +/// `script FILE.jsx [project]` / `script --eval CODE`: run JavaScript against a headless session +/// (an empty project unless one is given, or `--demo`) or the running app (`--bridge`). +fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { + let (code, name) = match (args.opt("--eval"), args.pos.first()) { + (Some(c), _) => (c.to_string(), "eval".to_string()), + (None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()), + (None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"), + }; + let mut b = backend(args, false)?; + let r = b.exec("script.run", json!({"code": code, "name": name}))?; + let saved = maybe_save(&mut b, args)?; + if json_out { + let mut o = r.clone(); + if let Some(p) = &saved { + o["saved"] = json!(p); + } + emit(&o, true); + } else { + if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) { + println!("{out}"); + } + if !r["result"].is_null() && r["error"].is_null() { + println!("{}", r["result"]); + } + if let Some(p) = saved { + eprintln!("saved {p}"); + } + } + if let Some(e) = r.get("error").filter(|e| !e.is_null()) { + let pos = match (e["line"].as_u64(), e["column"].as_u64()) { + (Some(l), Some(c)) => format!(":{l}:{c}"), + (Some(l), None) => format!(":{l}"), + _ => String::new(), + }; + let msg = format!("{}{pos}: {}", e["file"].as_str().unwrap_or(&name), e["message"].as_str().unwrap_or("script error")); + if json_out { + std::process::exit(1); + } + return Err(Failure::Error(msg)); + } + Ok(()) +} + /// Print a result: compact JSON with `--json`, else pretty JSON. fn emit(v: &Value, json_out: bool) { if json_out { @@ -315,6 +363,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { } } } + "script" => script_cmd(args, json_out)?, "exec" => { let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") }; let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index c8427720..d7c48762 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -131,3 +131,34 @@ fn mcp_over_stdio() { assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } + +#[test] +fn script_file_and_eval() { + let proj = tmp("scripted.ecproj"); + let p = proj.to_str().unwrap(); + let jsx = tmp("build.jsx"); + std::fs::write( + &jsx, + "app.beginUndoGroup('Build');\nvar c = app.project.items.addComp('FromJsx', 160, 90, 1, 2, 24);\nc.layers.addSolid([1, 0, 0], 'Red', 160, 90, 1);\napp.endUndoGroup();\nwriteLn('built ' + c.name);\nc.numLayers", + ) + .unwrap(); + let v = ok_json(&["script", jsx.to_str().unwrap(), "--save-as", p]); + assert_eq!(v["ok"], json!(true), "{v}"); + assert_eq!(v["result"], json!(1)); + assert_eq!(v["output"], json!("built FromJsx")); + assert_eq!(v["saved"], p); + // Run against the saved project (positional), plain output. + let out = bin().args(["script", "--eval", "app.project.item(1).name + ' has ' + app.project.item(1).numLayers", p]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "\"FromJsx has 1\""); + // Errors: exit 1 with file:line:col. + let bad = tmp("bad.jsx"); + std::fs::write(&bad, "var a = 1;\n\nmissingFunction();\n").unwrap(); + let out = bin().args(["script", bad.to_str().unwrap()]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("bad.jsx:3:1") && err.contains("missingFunction"), "{err}"); + let (code, v) = run_json(&["script", "--eval", "\nnope()"]); + assert_eq!(code, 1); + assert_eq!(v["error"]["line"], json!(2)); +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 7a765bfc..edd90e31 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -105,8 +105,8 @@ fn query_flag(name: &str) -> bool { web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name)) } -/// A session wired for the browser: media decoding from memory, expressions, Render Queue export -/// to downloads. +/// A session wired for the browser: media decoding from memory, expressions, scripting, Render +/// Queue export to downloads. pub fn session() -> Session { let pool = Arc::new(effectcraft_media::MediaPool::new()); Session { @@ -115,6 +115,7 @@ pub fn session() -> Session { importer: Some(Arc::new(files::WebImporter { pool })), expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), + script: Some(effectcraft_host::script::runner), exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })), ..Default::default() } diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a3b16408..0b2bcb05 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,3 +14,6 @@ effectcraft-engine = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } image = { workspace = true } + +[dev-dependencies] +effectcraft-script = { workspace = true } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index f175b12f..848d3ee0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -54,6 +54,7 @@ fn initialize_and_list_tools() { for want in [ "list_commands", "execute_command", + "run_script", "get_project", "get_comp", "get_layer", @@ -263,3 +264,30 @@ fn bridge_rejects_non_loopback() { assert!(Backend::bridge("10.0.0.1:9877").is_err()); assert!(Backend::bridge("localhost:9877").is_ok()); } + +#[test] +fn run_script_round_trip() { + let mut sess = Session::default(); + effectcraft_script::install(&mut sess); + let mut s = McpServer::new(Backend::headless(sess)); + let r = call_json( + &mut s, + "run_script", + json!({"code": "var c = app.project.items.addComp('Scripted', 320, 180, 1, 2, 24);\nc.layers.addText('Hi');\nwriteLn('made ' + c.name);\nc.numLayers"}), + ); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(r["result"], json!(1)); + assert_eq!(r["output"], json!("made Scripted")); + // The edit is visible to the other tools. + let p = call_json(&mut s, "get_project", json!({})); + assert!(p.to_string().contains("Scripted"), "{p}"); + // Errors come back with their line. + let r = call_json(&mut s, "run_script", json!({"code": "var a = 1;\nundefinedFn();", "name": "bad.jsx"})); + assert_eq!(r["ok"], json!(false)); + assert_eq!(r["error"]["line"], json!(2)); + assert_eq!(r["error"]["file"], json!("bad.jsx")); + // Without a scripting engine the command explains itself. + let mut bare = server(); + let (c, err) = call(&mut bare, "run_script", json!({"code": "1"})); + assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 205573b6..739c5524 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -140,6 +140,15 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec(id, params)?) } +fn run_script(b: &mut Backend, a: &Value) -> Result { + let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?; + let mut p = json!({"code": code}); + if let Some(n) = get(a, "name") { + p["name"] = n.clone(); + } + json_reply(b.exec("script.run", p)?) +} + fn get_project(b: &mut Backend, _: &Value) -> Result { json_reply(b.exec("project.summary", json!({}))?) } @@ -386,6 +395,21 @@ static TOOLS: &[ToolDef] = &[ }, run: execute_command, }, + ToolDef { + name: "run_script", + description: "Run JavaScript with an After Effects-style scripting object model (app, app.project, items.addComp, comp.layers.addSolid/addText/addShape/addNull/addCamera/addLight, layer.property(\"ADBE Transform Group\").property(\"ADBE Position\").setValueAtTime(t, v), Effects.addProperty(\"ADBE Gaussian Blur 2\"), expressions, markers, renderQueue…). Every edit is an undoable engine command; app.beginUndoGroup/endUndoGroup make one undo step. Returns {ok, result (last expression value), output (writeLn/$.writeln/alert text), error: {message, line, column} | null}. File access is limited to the project folder unless the user allows scripts to write files.", + bridge_only: false, + schema: || { + schema( + json!({ + "code": {"type": "string", "description": "JavaScript source, e.g. `var c = app.project.items.addComp(\"Main\", 1920, 1080, 1, 5, 30); c.layers.addText(\"Hi\"); c.numLayers`."}, + "name": {"type": "string", "description": "Script name used in error messages (default `script`)."} + }), + &["code"], + ) + }, + run: run_script, + }, ToolDef { name: "get_project", description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.", diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 87c1e2df..5ff744c3 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -358,9 +358,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { // ---------------------------------------------------------------- scripts -/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also -/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`). +/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After +/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON +/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's +/// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { + if let Some(path) = str_p(p, "path") + && p.get("steps").is_none() + && is_js_script(path) + { + if path.to_ascii_lowercase().ends_with(".jsxbin") { + return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source")); + } + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let code = String::from_utf8_lossy(&bytes).to_string(); + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let out = run_js(s, &code, path, false)?; + if let Some(e) = out.get("error").filter(|e| !e.is_null()) { + let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); + let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); + s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true }); + return Err(EngineError::Other(format!("{name}{line}: {msg}"))); + } + return Ok(out); + } let steps: Vec = match (p.get("steps"), str_p(p, "path")) { (Some(Value::Array(a)), _) => a.clone(), (_, Some(path)) => { @@ -388,7 +409,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" { + if id == "file.runScript" || id == "script.run" { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; @@ -397,6 +418,28 @@ fn run_script(s: &mut Session, p: &Value) -> Result { Ok(json!({"steps": results.len(), "results": results})) } +/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are +/// command scripts. +fn is_js_script(path: &str) -> bool { + let l = path.to_ascii_lowercase(); + l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin") +} + +/// Run JavaScript through the session's scripting engine. +fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { + let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; + Ok(runner(s, &crate::ScriptRequest { code, name, console })) +} + +/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps, +/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in +/// `error` (with line and column), not as a command failure. +fn script_run(s: &mut Session, p: &Value) -> Result { + let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string(); + let name = str_p(p, "name").unwrap_or("script").to_string(); + run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false)) +} + // ---------------------------------------------------------------- footage fn interpret(s: &mut Session, p: &Value) -> Result { @@ -638,10 +681,19 @@ pub fn specs() -> Vec { "Run Script File...", ["File", "Scripts"], None, - "{path (.jsonl/.json command script) | steps: [{command, params}]}", + "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}", always, run_script ), + cmd!( + "script.run", + "Run Script", + [], + None, + "{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}", + always, + script_run + ), cmd!( "file.interpretFootage", "Main...", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4537752f..2e40eb63 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod paint; mod paths; mod project_items; mod prop; +mod prop_groups; pub mod puppet; mod query; mod render_queue; @@ -105,6 +106,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(text_edit::specs()); v.extend(layer_time::specs()); v.extend(prop::specs()); + v.extend(prop_groups::specs()); v.extend(project_items::specs()); v.extend(anim::specs()); v.extend(anim_tools::specs()); diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 832f5851..a3949896 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Delete project items (folders with their contents). Layers that use a deleted item and Render +/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel. +fn delete(s: &mut Session, p: &Value) -> Result { + let c = "project.delete"; + let ids = items_p(s, p, c)?; + let mut doomed: Vec = vec![]; + for id in s.project.items.keys() { + if ids.iter().any(|d| is_within(&s.project, *id, *d)) { + doomed.push(*id); + } + } + let n = doomed.len(); + s.edit("Delete Items", None, |proj, st| { + for id in &doomed { + proj.items.remove(id); + } + let comp_ids: Vec = proj.comps().map(|(id, _)| *id).collect(); + for cid in comp_ids { + let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i)))); + if !uses { + continue; + } + if let Some(comp) = proj.comp_mut(cid) { + let gone: Vec = + comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect(); + comp.layers.retain(|l| !gone.contains(&l.id)); + for l in &mut comp.layers { + if l.parent.is_some_and(|x| gone.contains(&x)) { + l.parent = None; + } + if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) { + l.track_matte = None; + } + } + } + } + proj.render_queue.retain(|r| !doomed.contains(&r.comp)); + st.project_selection.retain(|i| !doomed.contains(i)); + Ok(()) + })?; + s.sanitize_state(); + Ok(json!(n)) +} + +/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and +/// a numbered name; the copies are selected. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let c = "project.duplicate"; + let ids = items_p(s, p, c)?; + let mut created = vec![]; + s.edit("Duplicate", None, |proj, st| { + for id in &ids { + let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone(); + if it.is_folder() { + return Err(bad(c, "folders can't be duplicated")); + } + let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string(); + let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default(); + let mut kind = it.kind.clone(); + if let effectcraft_project::ItemKind::Comp(comp) = &mut kind { + let comp = std::sync::Arc::make_mut(comp); + let mut map = std::collections::HashMap::new(); + for l in &mut comp.layers { + let new = effectcraft_project::LayerId(proj.alloc()); + map.insert(l.id, new); + l.id = new; + let mut next = proj.next_id; + l.props.reassign_uids(&mut next); + proj.next_id = next + 1; + } + for l in &mut comp.layers { + l.parent = l.parent.and_then(|p| map.get(&p).copied()); + if let Some(m) = &mut l.track_matte { + m.layer = map.get(&m.layer).copied().unwrap_or(m.layer); + } + } + } + let nid = proj.add_item(&name, it.label, it.parent, kind); + created.push(nid); + } + st.project_selection = created.clone(); + Ok(()) + })?; + Ok(json!({"items": created.iter().map(|i| i.0).collect::>()})) +} + pub fn specs() -> Vec { vec![ cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select), @@ -147,5 +233,15 @@ pub fn specs() -> Vec { cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items), cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label), cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment), + cmd!( + "project.delete", + "Delete Project Items", + [], + None, + "{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)", + always, + delete + ), + cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate), ] } diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs new file mode 100644 index 00000000..7f5f2a2c --- /dev/null +++ b/crates/engine/src/commands/prop_groups.rs @@ -0,0 +1,107 @@ +//! Generic edits of property-tree groups by uid: rename, enable, remove, duplicate and reorder an +//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`, +//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection). + +use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists. +fn is_instance(g: &PropGroup) -> bool { + matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. }) +} + +/// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance. +fn group_ref(s: &Session, p: &Value, cmd: &str, need_instance: bool) -> Result<(ItemId, LayerId, Uid)> { + let (cid, lid) = layer_p(s, p, cmd)?; + let uid = p.get("prop").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `prop` (group uid)"))?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?; + if need_instance && !is_instance(g) { + return Err(bad(cmd, format!("`{}` is a fixed group (only effects, masks, shape items and animators)", g.name))); + } + Ok((cid, lid, uid)) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let c = "prop.renameGroup"; + let (cid, lid, uid) = group_ref(s, p, c, true)?; + let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?.to_string(); + if name.trim().is_empty() { + return Err(bad(c, "the name can't be empty")); + } + s.edit("Rename", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; + g.name = name.clone(); + Ok(json!(name)) + }) +} + +fn set_enabled(s: &mut Session, p: &Value) -> Result { + let c = "prop.setGroupEnabled"; + let (cid, lid, uid) = group_ref(s, p, c, true)?; + let v = b_p(p, "value"); + s.edit("Toggle", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; + g.enabled = v.unwrap_or(!g.enabled); + Ok(json!(g.enabled)) + }) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let c = "prop.removeGroup"; + let (cid, lid, uid) = group_ref(s, p, c, true)?; + s.edit("Delete", None, |proj, st| { + let l = layer_mut(proj, cid, lid)?; + let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; + parent.children.retain(|n| n.uid() != uid); + st.selected_props.retain(|(_, u)| *u != uid); + Ok(Value::Null) + }) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let c = "prop.duplicateGroup"; + let (cid, lid, uid) = group_ref(s, p, c, true)?; + s.edit("Duplicate", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; + let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?; + let Node::Group(mut g) = parent.children[i].clone() else { return Err(bad(c, "not a group")) }; + g.reassign_uids(&mut next); + let new = g.uid; + parent.children.insert(i + 1, Node::Group(g)); + proj.next_id = next + 1; + Ok(json!({"prop": new})) + }) +} + +fn move_to(s: &mut Session, p: &Value) -> Result { + let c = "prop.moveGroup"; + let (cid, lid, uid) = group_ref(s, p, c, true)?; + let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (1-based)"))? as usize; + s.edit("Reorder", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?; + let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?; + if to < 1 || to > parent.children.len() { + return Err(bad(c, format!("index {to} is out of range 1..{}", parent.children.len()))); + } + let n = parent.children.remove(i); + parent.children.insert(to - 1, n); + Ok(json!(to)) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!("prop.renameGroup", "Rename Property Group", [], None, "{layer?, prop: uid, name}", has_layers, rename), + cmd!("prop.setGroupEnabled", "Enable Property Group", [], None, "{layer?, prop: uid, value?}", has_layers, set_enabled), + cmd!("prop.removeGroup", "Delete Property Group", [], None, "{layer?, prop: uid}", has_layers, remove), + cmd!("prop.duplicateGroup", "Duplicate Property Group", [], None, "{layer?, prop: uid} → {prop: new uid}", has_layers, duplicate), + cmd!("prop.moveGroup", "Reorder Property Group", [], None, "{layer?, prop: uid, index (1-based among its siblings)}", has_layers, move_to), + ] +} diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 73f34c5e..51367da9 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1)); let mut g = build::text_animator(&mut ids, &name, vec![]); let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum(); - if added == 0 { + // `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")). + if added == 0 && !kinds.is_empty() { return Err(bad(C, "unknown animator property")); } let uid = g.uid; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba34488a..063b9bdf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -285,8 +285,25 @@ pub struct Session { pub autosave: autosave::AutoSaveState, /// The viewer snapshot (Take Snapshot / Show Snapshot). pub snapshot: Option, + /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): + /// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console. + pub script: Option, } +/// A script to run (see [`Session::script`]). +#[derive(Clone, Copy, Debug)] +pub struct ScriptRequest<'a> { + pub code: &'a str, + /// File name shown in errors and `$.fileName` (`console` for the Script Console). + pub name: &'a str, + /// Run in the Script Console's persistent context (variables survive between runs). + pub console: bool, +} + +/// Runs a script against the session and reports `{ok, result, output, error: {message, line, +/// column, file} | null}`. +pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; + impl Default for Session { fn default() -> Self { Session { @@ -319,6 +336,7 @@ impl Default for Session { shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), snapshot: None, + script: None, } } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 38252a88..065d1762 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -942,6 +942,7 @@ Window Preview | window.panel {"panel":"preview"} | Cmd+3 Progress | window.unavailablePanel {"panel":"progress"} Properties | window.panel {"panel":"properties"} + Script Console | window.panel {"panel":"scriptConsole"} Smoother | window.panel {"panel":"smoother"} Tools | window.panel {"panel":"tools"} | Cmd+1 Tracker | window.panel {"panel":"tracker"} diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 4d097aca..4ab9ce2b 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -39,3 +39,60 @@ fn move_into_folder_rename_label_comment_with_undo() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.item(comp).unwrap().comment, ""); } + +#[test] +fn delete_and_duplicate_items() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newNull", json!({})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap(); + // Duplicate: a new comp with fresh layer ids. + let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap(); + let dup = ItemId(r["items"][0].as_u64().unwrap()); + assert_eq!(s.project.item(dup).unwrap().name, "Inner 2"); + let a = s.project.comp(inner).unwrap().layers[0].id; + let b = s.project.comp(dup).unwrap().layers[0].id; + assert_ne!(a, b); + // Delete: layers that use the item and its render items go too; undoable. + s.execute("project.delete", json!({"items": ["Inner"]})).unwrap(); + assert!(s.project.item(inner).is_none()); + assert!(s.project.comp(outer).unwrap().layers.is_empty()); + assert!(s.project.render_queue.is_empty()); + s.undo(); + assert!(s.project.item(inner).is_some()); + assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1); + // Folders take their contents. + let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap(); + s.execute("project.delete", json!({"items": [f.0]})).unwrap(); + assert!(s.project.item(dup).is_none()); +} + +#[test] +fn property_group_edits() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap(); + let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap(); + let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap(); + let fx = |s: &Session| -> Vec<(u64, String, bool)> { + s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect() + }; + s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap(); + s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap(); + s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap(); + let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap(); + let v = fx(&s); + assert_eq!(v.iter().map(|x| x.0).collect::>(), [b, a, d]); + assert_eq!(v[1].1, "Soft"); + assert!(!v[1].2); + s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap(); + assert_eq!(fx(&s).len(), 2); + // Fixed groups can't be removed. + let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid; + assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err()); +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index b43271c7..dc7965d9 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-host" -description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer and the expression engine" +description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer, the expression engine and scripting" version.workspace = true edition.workspace = true license.workspace = true @@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true } effectcraft-project = { workspace = true } effectcraft-media = { workspace = true } effectcraft-expr = { workspace = true } +effectcraft-script = { workspace = true } effectcraft-export = { workspace = true } [dev-dependencies] diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 16199dfb..5235feca 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -1,12 +1,14 @@ //! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs), -//! the media importer, the expression engine and Render Queue export through -//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here. +//! the media importer, the expression engine, JavaScript scripting (`effectcraft-script`) and +//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, +//! CLI, MCP, web) start here. use std::sync::Arc; use effectcraft_engine::project::render_queue::OutputFormat; use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session}; use effectcraft_project::Footage; +pub use effectcraft_script as script; struct MediaImporter; @@ -47,7 +49,7 @@ impl Exporter for FileExporter { } } -/// A new session with media, import, expressions and export enabled. +/// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { Session { exporter: Some(Arc::new(FileExporter::default())), @@ -55,6 +57,7 @@ pub fn session() -> Session { importer: Some(Arc::new(MediaImporter)), expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), + script: Some(effectcraft_script::runner), ..Default::default() } } @@ -188,6 +191,35 @@ mod tests { assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0); } + /// A script builds a comp, queues it and renders it through the Render Queue. + #[test] + fn script_renders_through_the_render_queue() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script"); + let _ = std::fs::remove_dir_all(&dir); + let out = dir.join("scripted.gif"); + let mut s = super::session(); + let code = format!( + r#" + var comp = app.project.items.addComp("Scripted", 64, 48, 1, 0.2, 10); + var sq = comp.layers.addSolid([1, 0.5, 0], "Square", 16, 16, 1); + sq.transform.position.setValueAtTime(0, [8, 24]); + sq.transform.position.setValueAtTime(0.1, [56, 24]); + var item = app.project.renderQueue.items.add(comp); + item.outputModule(1).applyTemplate("GIF"); + item.outputModule(1).file = new File({}); + app.project.renderQueue.render(); + [item.status == RQItemStatus.DONE, item.outputModule(1).file.fsName, app.project.renderQueue.numItems] + "#, + serde_json::to_string(&out.to_string_lossy()).unwrap() + ); + let r = s.execute("script.run", json!({"code": code})).unwrap(); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(r["result"][0], json!(true), "{r}"); + assert_eq!(r["result"][2], json!(1)); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"GIF89a")); + } + #[test] fn expression_syntax_errors_disable_the_expression() { let mut s = super::session(); diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml new file mode 100644 index 00000000..ef756107 --- /dev/null +++ b/crates/script/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "effectcraft-script" +description = "EffectCraft scripting: JavaScript (boa) with an After Effects-style object model (app, project, CompItem, Layer, Property…) over the engine's undoable commands" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-engine = { workspace = true } +effectcraft-project = { workspace = true } +effectcraft-keyframe = { workspace = true } +effectcraft-time = { workspace = true } +effectcraft-render = { workspace = true } +effectcraft-path = { workspace = true } +serde_json = { workspace = true } +boa_engine = { workspace = true } + +# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time). +[target.'cfg(target_arch = "wasm32")'.dependencies] +boa_engine = { workspace = true, features = ["js"] } + +[dev-dependencies] +effectcraft-expr = { workspace = true } diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs new file mode 100644 index 00000000..a1e9e5d4 --- /dev/null +++ b/crates/script/src/lib.rs @@ -0,0 +1,64 @@ +//! # effectcraft-script (L4) +//! +//! Scripting with an After Effects-style object model, on the boa JavaScript engine (the one the +//! expressions use). Scripts written for After Effects' documented scripting API mostly run +//! unchanged: +//! +//! ```text +//! app.beginUndoGroup("Build"); +//! var comp = app.project.items.addComp("Main", 1920, 1080, 1, 10, 30); +//! var solid = comp.layers.addSolid([1, 0, 0], "Red", 1920, 1080, 1); +//! solid.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(0, [0, 540]); +//! solid.Effects.addProperty("ADBE Gaussian Blur 2").property("Blurriness").setValue(20); +//! app.endUndoGroup(); +//! ``` +//! +//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups, +//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`, +//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/ +//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, +//! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`, +//! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/ +//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, +//! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…). +//! * **Edits are engine commands**: every mutating call runs a command through +//! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled +//! and identical to the UI's action; `app.beginUndoGroup`/`endUndoGroup` fold everything in +//! between into one undo step. +//! * **Match names** ([`matchnames`]): After Effects' documented match names for our properties +//! and effects (`ADBE Transform Group`, `ADBE Position`, `ADBE Gaussian Blur 2`…). +//! * **Security**: scripts can import/open/save projects and render, but `File` reads are limited +//! to the project's folder and writes (and the network) are off unless Preferences ▸ Scripting +//! & Expressions ▸ Allow Scripts to Write Files and Access Network is on. +//! +//! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which +//! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console +//! panel, `effectcraft-cli script` and the MCP `run_script` tool use. + +#![recursion_limit = "256"] + +pub mod matchnames; +mod model; +mod runtime; + +use effectcraft_engine::{ScriptRequest, Session}; + +pub use runtime::{Outcome, ScriptError, run}; + +/// The [`effectcraft_engine::ScriptRunner`] this crate provides. +pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value { + run(s, req).to_json() +} + +/// Enable scripting on a session. +pub fn install(s: &mut Session) { + s.script = Some(runner); +} + +/// Run `code` as a one-off script named `name`. +pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { + run(s, &ScriptRequest { code, name, console: false }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs new file mode 100644 index 00000000..623027c1 --- /dev/null +++ b/crates/script/src/matchnames.rs @@ -0,0 +1,443 @@ +//! After Effects-compatible match names for our property tree and effects, so common scripts +//! (`layer.property("ADBE Transform Group").property("ADBE Position")`, +//! `layer.Effects.addProperty("ADBE Gaussian Blur 2")`) work unchanged. +//! +//! The names are the stable identifiers After Effects documents for scripting (Scripting Guide +//! ▸ match names). Properties without a documented equivalent keep our own match id +//! (`perChar3d`, `ec.blur.bilateral`), which `property()` and `addProperty()` accept too. + +use effectcraft_project::{LayerSource, Node}; + +/// Match name of a layer (its root property group). +pub fn layer(source: &LayerSource) -> &'static str { + match source { + LayerSource::Text => "ADBE Text Layer", + LayerSource::Shape => "ADBE Vector Layer", + LayerSource::Camera => "ADBE Camera Layer", + LayerSource::Light { .. } => "ADBE Light Layer", + _ => "ADBE AV Layer", + } +} + +/// Top-level groups of a layer. +const LAYER_GROUPS: &[(&str, &str)] = &[ + ("transform", "ADBE Transform Group"), + ("masks", "ADBE Mask Parade"), + ("effects", "ADBE Effect Parade"), + ("text", "ADBE Text Properties"), + ("contents", "ADBE Root Vectors Group"), + ("materialOptions", "ADBE Material Options Group"), + ("cameraOptions", "ADBE Camera Options Group"), + ("lightOptions", "ADBE Light Options Group"), + ("audio", "ADBE Audio Group"), + ("layerStyles", "ADBE Layer Styles"), + ("motionTrackers", "ADBE MTrackers"), + ("timeRemap", "ADBE Time Remapping"), +]; + +const TRANSFORM: &[(&str, &str)] = &[ + ("anchor", "ADBE Anchor Point"), + // Cameras and lights store the Point of Interest where AVLayers keep the Anchor Point. + ("poi", "ADBE Anchor Point"), + ("position", "ADBE Position"), + ("positionX", "ADBE Position_0"), + ("positionY", "ADBE Position_1"), + ("positionZ", "ADBE Position_2"), + ("scale", "ADBE Scale"), + ("orientation", "ADBE Orientation"), + ("rotationX", "ADBE Rotate X"), + ("rotationY", "ADBE Rotate Y"), + ("rotation", "ADBE Rotate Z"), + ("opacity", "ADBE Opacity"), +]; + +const MASK: &[(&str, &str)] = + &[("path", "ADBE Mask Shape"), ("feather", "ADBE Mask Feather"), ("opacity", "ADBE Mask Opacity"), ("expansion", "ADBE Mask Offset")]; + +const TEXT: &[(&str, &str)] = &[ + ("sourceText", "ADBE Text Document"), + ("pathOptions", "ADBE Text Path Options"), + ("moreOptions", "ADBE Text More Options"), + ("animators", "ADBE Text Animators"), +]; + +const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; + +const CAMERA: &[(&str, &str)] = &[ + ("zoom", "ADBE Camera Zoom"), + ("dof", "ADBE Camera Depth of Field"), + ("focusDistance", "ADBE Camera Focus Distance"), + ("aperture", "ADBE Camera Aperture"), + ("blurLevel", "ADBE Camera Blur Level"), +]; + +const LIGHT: &[(&str, &str)] = &[ + ("intensity", "ADBE Light Intensity"), + ("color", "ADBE Light Color"), + ("coneAngle", "ADBE Light Cone Angle"), + ("coneFeather", "ADBE Light Cone Feather 2"), + ("falloff", "ADBE Light Falloff Type"), + ("radius", "ADBE Light Falloff Start"), + ("falloffDistance", "ADBE Light Falloff Distance"), + ("castsShadows", "ADBE Light Casts Shadows"), + ("shadowDarkness", "ADBE Light Shadow Darkness"), + ("shadowDiffusion", "ADBE Light Shadow Diffusion"), +]; + +const MATERIAL: &[(&str, &str)] = &[ + ("castsShadows", "ADBE Casts Shadows"), + ("lightTransmission", "ADBE Light Transmission"), + ("acceptsShadows", "ADBE Accepts Shadows"), + ("acceptsLights", "ADBE Accepts Lights"), + ("ambient", "ADBE Ambient Coefficient"), + ("diffuse", "ADBE Diffuse Coefficient"), + ("specularIntensity", "ADBE Specular Coefficient"), + ("specularShininess", "ADBE Shininess Coefficient"), + ("metal", "ADBE Metal Coefficient"), +]; + +/// Shape items (Contents ▸ Add): our match id → AE match name. +pub const SHAPE_ITEMS: &[(&str, &str)] = &[ + ("group", "ADBE Vector Group"), + ("rect", "ADBE Vector Shape - Rect"), + ("ellipse", "ADBE Vector Shape - Ellipse"), + ("star", "ADBE Vector Shape - Star"), + ("path", "ADBE Vector Shape - Group"), + ("fill", "ADBE Vector Graphic - Fill"), + ("stroke", "ADBE Vector Graphic - Stroke"), + ("gfill", "ADBE Vector Graphic - G-Fill"), + ("gstroke", "ADBE Vector Graphic - G-Stroke"), + ("merge", "ADBE Vector Filter - Merge"), + ("offset", "ADBE Vector Filter - Offset"), + ("pucker", "ADBE Vector Filter - PB"), + ("repeater", "ADBE Vector Filter - Repeater"), + ("round", "ADBE Vector Filter - RC"), + ("trim", "ADBE Vector Filter - Trim"), + ("twist", "ADBE Vector Filter - Twist"), + ("wiggle", "ADBE Vector Filter - Roughen"), + ("zigzag", "ADBE Vector Filter - Zigzag"), +]; + +/// Properties inside shape items: (item match id, property match id, AE match name). +const SHAPE_PROPS: &[(&str, &str, &str)] = &[ + ("group", "blend", "ADBE Vector Blend Mode"), + ("group", "contents", "ADBE Vectors Group"), + ("group", "transform", "ADBE Vector Transform Group"), + ("transform", "anchor", "ADBE Vector Anchor"), + ("transform", "position", "ADBE Vector Position"), + ("transform", "scale", "ADBE Vector Scale"), + ("transform", "skew", "ADBE Vector Skew"), + ("transform", "skewAxis", "ADBE Vector Skew Axis"), + ("transform", "rotation", "ADBE Vector Rotation"), + ("transform", "opacity", "ADBE Vector Group Opacity"), + ("rect", "direction", "ADBE Vector Shape Direction"), + ("rect", "size", "ADBE Vector Rect Size"), + ("rect", "position", "ADBE Vector Rect Position"), + ("rect", "roundness", "ADBE Vector Rect Roundness"), + ("ellipse", "direction", "ADBE Vector Shape Direction"), + ("ellipse", "size", "ADBE Vector Ellipse Size"), + ("ellipse", "position", "ADBE Vector Ellipse Position"), + ("star", "direction", "ADBE Vector Shape Direction"), + ("star", "type", "ADBE Vector Star Type"), + ("star", "points", "ADBE Vector Star Points"), + ("star", "position", "ADBE Vector Star Position"), + ("star", "rotation", "ADBE Vector Star Rotation"), + ("star", "innerRadius", "ADBE Vector Star Inner Radius"), + ("star", "outerRadius", "ADBE Vector Star Outer Radius"), + ("star", "innerRoundness", "ADBE Vector Star Inner Roundess"), + ("star", "outerRoundness", "ADBE Vector Star Outer Roundess"), + ("path", "direction", "ADBE Vector Shape Direction"), + ("path", "path", "ADBE Vector Shape"), + ("fill", "blend", "ADBE Vector Blend Mode"), + ("fill", "composite", "ADBE Vector Composite Order"), + ("fill", "rule", "ADBE Vector Fill Rule"), + ("fill", "color", "ADBE Vector Fill Color"), + ("fill", "opacity", "ADBE Vector Fill Opacity"), + ("stroke", "blend", "ADBE Vector Blend Mode"), + ("stroke", "composite", "ADBE Vector Composite Order"), + ("stroke", "color", "ADBE Vector Stroke Color"), + ("stroke", "opacity", "ADBE Vector Stroke Opacity"), + ("stroke", "width", "ADBE Vector Stroke Width"), + ("stroke", "cap", "ADBE Vector Stroke Line Cap"), + ("stroke", "join", "ADBE Vector Stroke Line Join"), + ("stroke", "miter", "ADBE Vector Stroke Miter Limit"), + ("stroke", "dashes", "ADBE Vector Stroke Dashes"), + ("trim", "start", "ADBE Vector Trim Start"), + ("trim", "end", "ADBE Vector Trim End"), + ("trim", "offset", "ADBE Vector Trim Offset"), + ("trim", "mode", "ADBE Vector Trim Type"), + ("repeater", "copies", "ADBE Vector Repeater Copies"), + ("repeater", "offset", "ADBE Vector Repeater Offset"), + ("repeater", "composite", "ADBE Vector Repeater Order"), + ("repeater", "transform", "ADBE Vector Repeater Transform"), + ("round", "radius", "ADBE Vector RoundCorner Radius"), + ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), + ("twist", "angle", "ADBE Vector Twist Angle"), + ("twist", "center", "ADBE Vector Twist Center"), + ("merge", "mode", "ADBE Vector Merge Type"), +]; + +/// Effects: our effect id → AE match name (the documented ones; the rest keep our id). +pub const EFFECTS: &[(&str, &str)] = &[ + ("ec.blur.gaussian", "ADBE Gaussian Blur 2"), + ("ec.blur.fastbox", "ADBE Box Blur2"), + ("ec.blur.directional", "ADBE Motion Blur"), + ("ec.blur.radial", "ADBE Radial Blur"), + ("ec.blur.cameralens", "ADBE Camera Lens Blur"), + ("ec.blur.channel", "ADBE Channel Blur"), + ("ec.blur.compound", "ADBE Compound Blur"), + ("ec.blur.sharpen", "ADBE Sharpen"), + ("ec.blur.smart", "ADBE Smart Blur"), + ("ec.blur.unsharp", "ADBE Unsharp Mask2"), + ("ec.channel.invert", "ADBE Invert"), + ("ec.channel.setmatte", "ADBE Set Matte3"), + ("ec.channel.setchannels", "ADBE Set Channels"), + ("ec.channel.shiftchannels", "ADBE Shift Channels"), + ("ec.channel.minimax", "ADBE Minimax"), + ("ec.channel.blend", "ADBE Blend"), + ("ec.channel.arithmetic", "ADBE Arithmetic"), + ("ec.channel.calculations", "ADBE Calculations"), + ("ec.channel.solidcomposite", "ADBE Solid Composite"), + ("ec.color.brightnesscontrast", "ADBE Brightness & Contrast 2"), + ("ec.color.curves", "ADBE CurvesCustom"), + ("ec.color.levels", "ADBE Easy Levels2"), + ("ec.color.levelsic", "ADBE Pro Levels2"), + ("ec.color.huesaturation", "ADBE HUE SATURATION"), + ("ec.color.exposure", "ADBE Exposure2"), + ("ec.color.tint", "ADBE Tint"), + ("ec.color.tritone", "ADBE Tritone"), + ("ec.color.blackwhite", "ADBE Black&White"), + ("ec.color.channelmixer", "ADBE CHANNEL MIXER"), + ("ec.color.colorbalance", "ADBE Color Balance 2"), + ("ec.color.colorbalancehls", "ADBE Color Balance (HLS)"), + ("ec.color.changecolor", "ADBE Change Color"), + ("ec.color.changetocolor", "ADBE Change To Color"), + ("ec.color.colorama", "APC Colorama"), + ("ec.color.leavecolor", "ADBE Leave Color"), + ("ec.color.photofilter", "ADBE Photo Filter"), + ("ec.color.vibrance", "ADBE Vibrance"), + ("ec.color.lumetri", "ADBE Lumetri"), + ("ec.color.shadowhighlight", "ADBE Shadow/Highlight"), + ("ec.color.selectivecolor", "ADBE Selective Color"), + ("ec.color.gammapedestalgain", "ADBE Gamma/Pedestal/Gain"), + ("ec.color.equalize", "ADBE Equalize"), + ("ec.color.autocontrast", "ADBE AutoContrast"), + ("ec.color.autolevels", "ADBE AutoLevels"), + ("ec.color.autocolor", "ADBE AutoColor"), + ("ec.control.slider", "ADBE Slider Control"), + ("ec.control.angle", "ADBE Angle Control"), + ("ec.control.checkbox", "ADBE Checkbox Control"), + ("ec.control.color", "ADBE Color Control"), + ("ec.control.point", "ADBE Point Control"), + ("ec.control.point3d", "ADBE Point3D Control"), + ("ec.control.layer", "ADBE Layer Control"), + ("ec.control.dropdown", "ADBE Dropdown Control"), + ("ec.distort.transform", "ADBE Geometry2"), + ("ec.distort.cornerpin", "ADBE Corner Pin"), + ("ec.distort.bulge", "ADBE Bulge"), + ("ec.distort.mirror", "ADBE Mirror"), + ("ec.distort.offset", "ADBE Offset"), + ("ec.distort.ripple", "ADBE Ripple"), + ("ec.distort.spherize", "ADBE Spherize"), + ("ec.distort.twirl", "ADBE Twirl"), + ("ec.distort.wavewarp", "ADBE Wave Warp"), + ("ec.distort.turbulentdisplace", "ADBE Turbulent Displace"), + ("ec.distort.displacementmap", "ADBE Displacement Map"), + ("ec.distort.meshwarp", "ADBE MESH WARP"), + ("ec.distort.polar", "ADBE Polar Coordinates"), + ("ec.distort.opticscompensation", "ADBE Optics Compensation"), + ("ec.distort.magnify", "ADBE Magnify"), + ("ec.distort.warpstabilizer", "ADBE SubspaceStabilizer"), + ("ec.distort.puppet", "ADBE FreePin3"), + ("ec.generate.fill", "ADBE Fill"), + ("ec.generate.gradientramp", "ADBE Ramp"), + ("ec.generate.fourcolor", "ADBE 4ColorGradient"), + ("ec.generate.stroke", "ADBE Stroke"), + ("ec.generate.grid", "ADBE Grid"), + ("ec.generate.checkerboard", "ADBE Checkerboard"), + ("ec.generate.circle", "ADBE Circle"), + ("ec.generate.ellipse", "ADBE Ellipse"), + ("ec.generate.cellpattern", "ADBE Cell Pattern"), + ("ec.generate.lensflare", "ADBE Lens Flare"), + ("ec.generate.vegas", "ADBE Vegas"), + ("ec.generate.writeon", "ADBE Write On"), + ("ec.generate.audiospectrum", "ADBE AudSpect"), + ("ec.generate.audiowaveform", "ADBE AudWave"), + ("ec.generate.beam", "ADBE Laser"), + ("ec.generate.radiowaves", "ADBE Radio Waves"), + ("ec.generate.scribble", "ADBE Scribble Fill"), + ("ec.generate.paintbucket", "ADBE Paint Bucket"), + ("ec.key.colorkey", "ADBE Color Key"), + ("ec.key.luma", "ADBE Luma Key"), + ("ec.key.linearcolor", "ADBE Linear Color Key2"), + ("ec.key.colorrange", "ADBE Color Range"), + ("ec.key.colordifference", "ADBE Color Difference Key"), + ("ec.key.extract", "ADBE Extract"), + ("ec.key.spill", "ADBE Spill2"), + ("ec.matte.simplechoker", "ADBE Simple Choker"), + ("ec.matte.mattechoker", "ADBE Matte Choker"), + ("ec.noise.noise", "ADBE Noise"), + ("ec.noise.fractal", "ADBE Fractal Noise"), + ("ec.noise.median", "ADBE Median"), + ("ec.noise.addgrain", "VISINF Grain Implant"), + ("ec.noise.dustscratches", "ADBE Dust & Scratches"), + ("ec.noise.turbulent", "ADBE Turbulent Noise"), + ("ec.perspective.dropshadow", "ADBE Drop Shadow"), + ("ec.perspective.radialshadow", "ADBE Radial Shadow"), + ("ec.perspective.bevelalpha", "ADBE Bevel Alpha"), + ("ec.perspective.beveledges", "ADBE Bevel Edges"), + ("ec.perspective.3dglasses", "ADBE 3D Glasses2"), + ("ec.stylize.glow", "ADBE Glo2"), + ("ec.stylize.mosaic", "ADBE Mosaic"), + ("ec.stylize.posterize", "ADBE Posterize"), + ("ec.stylize.threshold", "ADBE Threshold2"), + ("ec.stylize.findedges", "ADBE Find Edges"), + ("ec.stylize.emboss", "ADBE Emboss"), + ("ec.stylize.coloremboss", "ADBE Color Emboss"), + ("ec.stylize.roughenedges", "ADBE Roughen Edges"), + ("ec.stylize.scatter", "ADBE Scatter"), + ("ec.stylize.strobe", "ADBE Strobe"), + ("ec.stylize.motiontile", "ADBE Tile"), + ("ec.stylize.texturize", "ADBE Texturize"), + ("ec.stylize.cartoon", "ADBE Cartoonify"), + ("ec.stylize.brushstrokes", "ADBE Brush Strokes"), + ("ec.text.numbers", "ADBE Numbers2"), + ("ec.text.timecode", "ADBE Timecode"), + ("ec.time.echo", "ADBE Echo"), + ("ec.time.posterizetime", "ADBE Posterize Time"), + ("ec.time.timedisplacement", "ADBE Time Displacement"), + ("ec.time.timedifference", "ADBE Time Difference"), + ("ec.time.pixelmotionblur", "ADBE Pixel Motion Blur"), + ("ec.time.timewarp", "ADBE Timewarp"), + ("ec.transition.linearwipe", "ADBE Linear Wipe"), + ("ec.transition.radialwipe", "ADBE Radial Wipe"), + ("ec.transition.venetian", "ADBE Venetian Blinds"), + ("ec.transition.blockdissolve", "ADBE Block Dissolve"), + ("ec.transition.gradientwipe", "ADBE Gradient Wipe"), + ("ec.transition.iriswipe", "ADBE Iris Wipe"), + ("ec.utility.applylut", "ADBE Apply Color LUT2"), + ("ec.utility.cineon", "ADBE Cineon Converter2"), + ("ec.utility.growbounds", "ADBE Grow Bounds"), +]; + +fn find<'a>(t: &'a [(&str, &'a str)], k: &str) -> Option<&'a str> { + t.iter().find(|(a, _)| *a == k).map(|(_, b)| *b) +} + +/// AE match name of an effect id (falls back to the id). +pub fn effect(id: &str) -> String { + find(EFFECTS, id).map(str::to_string).unwrap_or_else(|| id.to_string()) +} + +/// Our effect id for an AE effect match name. +pub fn effect_from_match(m: &str) -> Option<&'static str> { + EFFECTS.iter().find(|(_, b)| b.eq_ignore_ascii_case(m)).map(|(a, _)| *a) +} + +/// Our shape item kind (`layer.addShapeItem`) for an AE match name, our match id or a display +/// name (`Rectangle`, `Fill`, `Trim Paths`). +pub fn shape_kind(name: &str) -> Option<&'static str> { + if let Some((k, _)) = SHAPE_ITEMS.iter().find(|(k, m)| m.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) { + return Some(k); + } + let n: String = name.chars().filter(|c| c.is_alphanumeric()).collect::().to_ascii_lowercase(); + Some(match n.as_str() { + "rectangle" | "rectanglepath" => "rect", + "ellipsepath" => "ellipse", + "polystar" | "polystarpath" => "star", + "polygon" => "polygon", + "group" | "vectorgroup" => "group", + "fill" => "fill", + "stroke" => "stroke", + "gradientfill" => "gfill", + "gradientstroke" => "gstroke", + "trimpaths" | "trim" => "trim", + "repeater" => "repeater", + "roundcorners" => "round", + "offsetpaths" => "offset", + "puckerbloat" | "puckerandbloat" => "pucker", + "twist" => "twist", + "zigzag" => "zigzag", + "wigglepaths" => "wiggle", + "mergepaths" => "merge", + _ => return None, + }) +} + +/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` +/// params are numbered `-0001…` in parameter order, like After Effects. +pub fn node(chain: &[&Node]) -> String { + let Some(last) = chain.last() else { return String::new() }; + let id = last.match_id(); + let parent = chain.len().checked_sub(2).map(|i| chain[i]); + let grand = chain.len().checked_sub(3).map(|i| chain[i]); + let top = chain.first().map(|n| n.match_id()).unwrap_or(""); + let own = || id.to_string(); + if chain.len() == 1 { + return find(LAYER_GROUPS, id).map(str::to_string).unwrap_or_else(own); + } + let pid = parent.map(|p| p.match_id()).unwrap_or(""); + match top { + "transform" if chain.len() == 2 => find(TRANSFORM, id).map(str::to_string).unwrap_or_else(own), + "masks" if chain.len() == 2 => "ADBE Mask Atom".into(), + "masks" if chain.len() == 3 => find(MASK, id).map(str::to_string).unwrap_or_else(own), + "effects" if chain.len() == 2 => effect(id), + "effects" if chain.len() == 3 => { + let fx = parent.and_then(Node::as_group); + match fx { + Some(g) => { + let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1; + format!("{}-{i:04}", effect(&g.match_id)) + } + None => own(), + } + } + "text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "animators" => "ADBE Text Animator".into(), + "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), + "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), + "lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own), + "materialOptions" => find(MATERIAL, id).map(str::to_string).unwrap_or_else(own), + "audio" if id == "levels" => "ADBE Audio Levels".into(), + "contents" => { + // Items sit in a `contents` list; their properties under the item. + if pid == "contents" { + if id == "star" || id == "polygon" { + return "ADBE Vector Shape - Star".into(); + } + return find(SHAPE_ITEMS, id).map(str::to_string).unwrap_or_else(own); + } + if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) { + return m.to_string(); + } + own() + } + _ => own(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effect_names_round_trip() { + for (id, m) in EFFECTS { + assert_eq!(effect(id), *m); + assert_eq!(effect_from_match(m), Some(*id)); + } + assert_eq!(effect("ec.blur.ccvector"), "ec.blur.ccvector"); + let mut ids: Vec<&str> = EFFECTS.iter().map(|(a, _)| *a).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), EFFECTS.len(), "duplicate effect ids"); + } + + #[test] + fn shape_kinds() { + assert_eq!(shape_kind("ADBE Vector Shape - Rect"), Some("rect")); + assert_eq!(shape_kind("Trim Paths"), Some("trim")); + assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill")); + assert_eq!(shape_kind("nope"), None); + } +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs new file mode 100644 index 00000000..d1dd4768 --- /dev/null +++ b/crates/script/src/model.rs @@ -0,0 +1,679 @@ +//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers, +//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through +//! engine commands instead (`__exec`), so it is undoable and journaled. + +use effectcraft_engine::Session; +use effectcraft_engine::color::Label; +use effectcraft_keyframe::{Interp, TextDoc, Value as KV}; +use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup}; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use serde_json::{Value as J, json}; + +use crate::matchnames; + +type R = Result; + +fn u(a: &J, k: &str) -> Option { + a.get(k).and_then(J::as_u64) +} +fn f(a: &J, k: &str) -> Option { + a.get(k).and_then(J::as_f64) +} + +fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> { + let id = ItemId(u(a, "comp").ok_or("missing comp")?); + s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string()) +} + +fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> { + let (cid, c) = comp_of(s, a)?; + let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?; + Ok((cid, c, l)) +} + +pub fn query(s: &mut Session, kind: &str, a: &J) -> R { + match kind { + "project" => Ok(project(s)), + "item" => item(s, a), + "layer" => layer(s, a), + "node" => node(s, a), + "child" => child(s, a), + "value" => value(s, a), + "keys" => keys(s, a), + "textdoc" => textdoc(s, a), + "markers" => markers(s, a), + "sourceRect" => source_rect(s, a), + "resolveAdd" => resolve_add(s, a), + "menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))), + "commandAt" => { + let i = u(a, "index").unwrap_or(0) as usize; + Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null)) + } + "exprError" => { + let code = a.get("code").and_then(J::as_str).unwrap_or(""); + Ok(match s.expr_check { + Some(check) => json!(check(code).err().unwrap_or_default()), + None => json!(""), + }) + } + "labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::>())), + "effects" => Ok(json!( + effectcraft_engine::effects::registry() + .iter() + .map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category})) + .collect::>() + )), + _ => Err(format!("unknown query `{kind}`")), + } +} + +fn project(s: &Session) -> J { + let p = &s.project; + let items: Vec = p.items.keys().map(|k| k.0).collect(); + let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0)); + json!({ + "items": items, + "active": active, + "selection": s.state.project_selection.iter().map(|i| i.0).collect::>(), + "path": s.path, + "dirty": s.is_dirty(), + "bitsPerChannel": p.settings.bit_depth.bits(), + "linearBlending": p.settings.blend_linear, + "linearizeWorkingSpace": p.settings.linearize, + "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), + "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, + "renderQueue": p.render_queue.len(), + "revision": s.revision, + }) +} + +fn label_index(l: Label) -> usize { + Label::ALL.iter().position(|x| *x == l).unwrap_or(0) +} + +fn item(s: &Session, a: &J) -> R { + let id = ItemId(u(a, "id").ok_or("missing id")?); + let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?; + let mut o = json!({ + "id": id.0, + "name": it.name, + "typeName": it.type_name(), + "parent": it.parent.map(|p| p.0).unwrap_or(0), + "comment": it.comment, + "label": label_index(it.label), + "selected": s.state.project_selection.contains(&id), + }); + let used_in: Vec = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect(); + o["usedIn"] = json!(used_in); + match &it.kind { + ItemKind::Folder => { + o["kind"] = json!("folder"); + o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::>()); + } + ItemKind::Comp(c) => { + let cid = id; + o["kind"] = json!("comp"); + let sel: Vec = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect(); + let fps = c.frame_rate.as_f64(); + if let Some(m) = o.as_object_mut() { + m.extend( + json!({ + "width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect, + "duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(), + "layers": c.layers.iter().map(|l| l.id.0).collect::>(), + "selectedLayers": sel, + "selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::>(), + "time": s.time_of(cid).seconds(), + "workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(), + "bgColor": c.background, "displayStartTime": c.display_start.seconds(), + "hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending, + "draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase, + "motionBlurSamplesPerFrame": c.motion_blur_samples, + "renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" }, + "open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid), + "hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })), + "posterTime": c.poster_time.seconds(), + }) + .as_object() + .cloned() + .unwrap_or_default(), + ) + } + } + ItemKind::Footage(fo) => { + o["kind"] = json!("footage"); + o["file"] = json!(fo.path); + o["width"] = json!(fo.width); + o["height"] = json!(fo.height); + o["pixelAspect"] = json!(fo.pixel_aspect); + o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() }); + o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() }); + o["hasVideo"] = json!(fo.has_video); + o["hasAudio"] = json!(fo.has_audio); + o["footageMissing"] = json!(fo.missing); + o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still); + o["loop"] = json!(fo.loop_count); + } + ItemKind::Solid(so) => { + o["kind"] = json!("solid"); + o["width"] = json!(so.width); + o["height"] = json!(so.height); + o["pixelAspect"] = json!(so.pixel_aspect); + o["color"] = json!(so.color); + o["duration"] = json!(0.0); + o["frameRate"] = json!(0.0); + o["hasVideo"] = json!(true); + o["hasAudio"] = json!(false); + o["isStill"] = json!(true); + } + } + Ok(o) +} + +fn layer(s: &Session, a: &J) -> R { + let (cid, c, l) = layer_of(s, a)?; + let kind = match &l.source { + LayerSource::Text => "text", + LayerSource::Shape => "shape", + LayerSource::Camera => "camera", + LayerSource::Light { .. } => "light", + _ => "av", + }; + let (w, h) = effectcraft_render::source_size(&s.project, l); + let t = s.time_of(cid); + Ok(json!({ + "id": l.id.0, + "comp": cid.0, + "kind": kind, + "name": l.name, + "index": c.index_of(l.id).unwrap_or(0), + "matchName": matchnames::layer(&l.source), + "root": l.props.uid, + "source": l.source.item().map(|i| i.0), + "nullLayer": matches!(l.source, LayerSource::Null), + "lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" }, + "enabled": l.switches.video, + "audioEnabled": l.switches.audio, + "solo": l.switches.solo, + "locked": l.switches.locked, + "shy": l.switches.shy, + "collapse": l.switches.collapse, + "quality": format!("{:?}", l.switches.quality), + "samplingQuality": format!("{:?}", l.switches.sampling), + "effectsActive": l.switches.effects, + "frameBlending": format!("{:?}", l.switches.frame_blend), + "motionBlur": l.switches.motion_blur, + "adjustmentLayer": l.switches.adjustment, + "threeDLayer": l.switches.three_d, + "guideLayer": l.switches.guide, + "preserveTransparency": l.preserve_transparency, + "inPoint": l.in_point.seconds(), + "outPoint": l.out_point.seconds(), + "startTime": l.start_time.seconds(), + "stretch": l.stretch, + "time": l.layer_time(t).seconds(), + "active": l.switches.video && l.is_active_at(t), + "parent": l.parent.map(|p| p.0), + "blendingMode": l.blend_mode.label(), + "trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})), + "isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)), + "label": label_index(l.label), + "comment": l.comment, + "selected": s.state.selected_layers.contains(&l.id), + "autoOrient": format!("{:?}", l.auto_orient), + "width": w, "height": h, + "hasVideo": l.source.is_av(), + "hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(), + "timeRemapEnabled": l.props.prop("timeRemap").is_some(), + })) +} + +/// Node at the end of a uid chain below the layer root, and the chain itself. +fn chain(l: &Layer, uid: u64) -> Option> { + l.props.node_chain(uid) +} + +/// PropertyValueType of a property. +fn value_type(p: &effectcraft_project::Property) -> &'static str { + if matches!(p.ui, ParamUi::Mask) { + return "MASK_INDEX"; + } + match &p.value { + KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD", + KV::Vec2(_) => { + if p.spatial { + "TwoD_SPATIAL" + } else { + "TwoD" + } + } + KV::Vec3(_) => { + if p.spatial { + "ThreeD_SPATIAL" + } else { + "ThreeD" + } + } + KV::Color(_) => "COLOR", + KV::Path(_) => "SHAPE", + KV::Text(_) => "TEXT_DOCUMENT", + KV::Layer(_) => "LAYER_INDEX", + KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE", + } +} + +/// Groups whose children are instances (effects, masks, shape items, animators). +fn indexed_parade(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers") +} + +fn node_json(c: &Comp, l: &Layer, uid: u64) -> R { + if uid == 0 || uid == l.props.uid { + let n = l.props.children.len(); + return Ok(json!({ + "uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer", + "index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n, + "children": l.props.children.iter().map(Node::uid).collect::>(), "propertyType": "NAMED_GROUP", + "enabled": l.switches.video, "canSetEnabled": true, + })); + } + let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?; + let node = *ch.last().ok_or("bad property")?; + let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props }; + let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0); + let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid }; + let match_name = matchnames::node(&ch); + let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents"); + let mut o = json!({ + "uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index, + "depth": ch.len(), "parent": parent_uid, "isLayer": false, + "isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })), + "isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })), + }); + match node { + Node::Group(g) => { + let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. }); + o["kind"] = json!("group"); + o["numProperties"] = json!(g.children.len()); + o["children"] = json!(g.children.iter().map(Node::uid).collect::>()); + o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" }); + o["enabled"] = json!(g.enabled); + o["canSetEnabled"] = json!(instance); + o["instance"] = json!(instance || instance_parent); + o["effectId"] = json!(match &g.kind { + GroupKind::Effect { effect } => Some(effect.clone()), + _ => None, + }); + if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind { + o["maskMode"] = json!(mode.label()); + o["inverted"] = json!(inverted); + o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]); + o["locked"] = json!(locked); + } + } + Node::Prop(p) => { + o["kind"] = json!("prop"); + o["propertyType"] = json!("PROPERTY"); + o["valueType"] = json!(value_type(p)); + o["type"] = json!(p.value.kind_name()); + o["dims"] = json!(p.value.dims()); + o["spatial"] = json!(p.spatial); + o["numKeys"] = json!(p.keys.len()); + o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default()); + o["expressionEnabled"] = json!(p.has_expression()); + o["canVaryOverTime"] = json!(!p.static_only); + o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden)); + o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden)); + o["enabled"] = json!(true); + o["canSetEnabled"] = json!(false); + o["isModified"] = json!(p.is_animated() || p.has_expression()); + if let ParamUi::Slider { min, max, .. } = p.ui { + o["min"] = json!(min); + o["max"] = json!(max); + } + if let ParamUi::Popup { options } = &p.ui { + o["options"] = json!(options); + } + o["ui"] = json!(match &p.ui { + ParamUi::Percent => "%", + ParamUi::Angle => "degrees", + ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels", + _ => "", + }); + // Position with Separate Dimensions. + o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some()); + o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ")); + } + } + Ok(o) +} + +fn node(s: &Session, a: &J) -> R { + let (_, c, l) = layer_of(s, a)?; + node_json(c, l, u(a, "uid").unwrap_or(0)) +} + +/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`. +fn loose(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).collect::().to_ascii_lowercase() +} + +/// AE-style aliases for our match ids (attribute names scripts use). +fn alias(k: &str) -> Option<&'static str> { + Some(match k { + "anchorpoint" => "anchor", + "pointofinterest" => "poi", + "xrotation" => "rotationX", + "yrotation" => "rotationY", + "zrotation" => "rotation", + "xposition" => "positionX", + "yposition" => "positionY", + "zposition" => "positionZ", + "effect" | "effects" => "effects", + "mask" | "masks" => "masks", + "content" | "contents" => "contents", + "maskshape" | "maskpath" => "path", + "maskfeather" => "feather", + "maskopacity" => "opacity", + "maskexpansion" | "maskoffset" => "expansion", + "sourcetext" => "sourceText", + "timeremap" => "timeRemap", + "audiolevels" => "levels", + _ => return None, + }) +} + +fn child(s: &Session, a: &J) -> R { + let (_, c, l) = layer_of(s, a)?; + let uid = u(a, "uid").unwrap_or(0); + let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? }; + let key = a.get("key").cloned().unwrap_or(J::Null); + let found = match &key { + J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), + J::String(k) => { + let chain_of = |n: &Node| -> String { + let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == *k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + } + _ => None, + }; + match found { + Some(n) => node_json(c, l, n.uid()), + None => Ok(J::Null), + } +} + +/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as +/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document. +fn js_value(c: &Comp, v: &KV) -> J { + match v { + KV::Bool(b) => json!(if *b { 1 } else { 0 }), + KV::Enum(i) => json!(*i + 1), + KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)), + KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}), + KV::Text(d) => text_json(d), + v => v.to_json(), + } +} + +/// A text document for `TextDocument`: the serialized document plus the first character's style. +fn text_json(d: &TextDoc) -> J { + let mut doc = serde_json::to_value(d).unwrap_or(J::Null); + let st = d.style_at(0); + if let Some(o) = doc.as_object_mut() { + o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st)); + o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0))); + } + doc +} + +fn value(s: &Session, a: &J) -> R { + let (cid, c, l) = layer_of(s, a)?; + let uid = u(a, "uid").ok_or("missing uid")?; + let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?; + let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); + let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false); + let v = if pre || !p.has_expression() { + p.value_at(l.layer_time(t)) + } else { + let mut ctx = EvalCtx::new(&s.project, cid, c, t); + ctx.expr = s.expr.as_deref(); + ctx.value(l, p) + }; + Ok(js_value(c, &v)) +} + +fn interp(i: Interp) -> &'static str { + match i { + Interp::Linear => "LINEAR", + Interp::Bezier => "BEZIER", + Interp::Hold => "HOLD", + } +} + +fn keys(s: &Session, a: &J) -> R { + let (cid, c, l) = layer_of(s, a)?; + let uid = u(a, "uid").ok_or("missing uid")?; + let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?; + let sel: Vec = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect(); + let _ = cid; + let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J { + let n = n.max(1); + // Influence is stored as a fraction; scripts see percent. + let mut v: Vec = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect(); + let d = e.first().cloned().unwrap_or_default(); + while v.len() < n { + v.push(json!({"speed": d.speed, "influence": d.influence * 100.0})); + } + J::Array(v) + }; + let dims = if p.spatial { 1 } else { p.value.dims().max(1) }; + let out: Vec = p + .keys + .iter() + .map(|k| { + json!({ + "time": l.comp_time(k.time).seconds(), + "layerTime": k.time.seconds(), + "value": js_value(c, &k.value), + "inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp), + "inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims), + "continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving, + "spatialIn": k.spatial_in, "spatialOut": k.spatial_out, + "spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto, + "selected": sel.contains(&k.time), + }) + }) + .collect(); + Ok(J::Array(out)) +} + +/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new +/// document (for `prop.set` / `prop.addKey`). +fn textdoc(s: &Session, a: &J) -> R { + let (cid, _, l) = layer_of(s, a)?; + let uid = u(a, "uid").ok_or("missing uid")?; + let p = l.props.find(uid).ok_or("the property no longer exists")?; + let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); + let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) }; + let mut d = *doc; + if let Some(attrs) = a.get("attrs").and_then(J::as_object) { + if let Some(text) = attrs.get("text").and_then(J::as_str) { + d.set_text(text); + } + for (k, v) in attrs { + if k == "text" || v.is_null() { + continue; + } + match d.set_attr(k, v, None) { + Ok(true) => {} + Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")), + Err(e) => return Err(format!("TextDocument.{k}: {e}")), + } + } + } + serde_json::to_value(&d).map_err(|e| e.to_string()) +} + +fn markers(s: &Session, a: &J) -> R { + let (_, c) = comp_of(s, a)?; + let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") { + Some(lid) => { + let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?; + (&l.markers, Some(l)) + } + None => (&c.markers, None), + }; + let out: Vec = list + .iter() + .map(|m| { + let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time); + json!({ + "time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url, + "frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected, + "cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(), + "eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation), + "params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(), + }) + }) + .collect(); + Ok(J::Array(out)) +} + +fn source_rect(s: &Session, a: &J) -> R { + let (cid, c, l) = layer_of(s, a)?; + let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); + let mut ctx = EvalCtx::new(&s.project, cid, c, t); + ctx.expr = s.expr.as_deref(); + let r = match &l.source { + LayerSource::Text => { + let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect(); + effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4]) + } + LayerSource::Shape => match l.props.sub("contents") { + Some(contents) => { + let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0); + if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] } + } + None => [0.0; 4], + }, + _ => { + let (w, h) = effectcraft_render::source_size(&s.project, l); + [0.0, 0.0, w as f64, h as f64] + } + }; + Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]})) +} + +/// What `PropertyGroup.addProperty(name)` adds to the group `uid`. +fn resolve_add(s: &Session, a: &J) -> R { + let (_, _, l) = layer_of(s, a)?; + let uid = u(a, "uid").unwrap_or(0); + let name = a.get("name").and_then(J::as_str).unwrap_or(""); + let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? }; + let in_contents = g.match_id == "contents"; + let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some()); + Ok(match g.match_id.as_str() { + "effects" => { + let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string())); + match id { + Some(id) => json!({"kind": "effect", "effect": id}), + None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}), + } + } + "masks" => { + if name == "ADBE Mask Atom" || loose(name) == "mask" { + json!({"kind": "mask"}) + } else { + json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")}) + } + } + "animators" => json!({"kind": "animator"}), + _ if in_contents && in_group => match matchnames::shape_kind(name) { + Some(k) => { + json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }}) + } + None => json!({"error": format!("Can not add \"{name}\" to Contents")}), + }, + "properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => { + let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid)); + match text_animator_property(name) { + Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}), + None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}), + } + } + "selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => { + let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid)); + let kind = match name { + "ADBE Text Wiggly Selector" => "wiggly", + "ADBE Text Expressible Selector" => "expression", + n if loose(n).contains("wiggly") => "wiggly", + n if loose(n).contains("expression") => "expression", + _ => "range", + }; + json!({"kind": "selector", "selector": kind, "animator": animator}) + } + _ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}), + }) +} + +/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match +/// name or a display name. +fn text_animator_property(name: &str) -> Option<&'static str> { + const AE: &[(&str, &str)] = &[ + ("ADBE Text Anchor Point 3D", "anchor"), + ("ADBE Text Position 3D", "position"), + ("ADBE Text Scale 3D", "scale"), + ("ADBE Text Skew", "skew"), + ("ADBE Text Rotation", "rotation"), + ("ADBE Text Opacity", "opacity"), + ("ADBE Text Fill Color", "fillColor"), + ("ADBE Text Fill Hue", "fillHue"), + ("ADBE Text Fill Saturation", "fillSaturation"), + ("ADBE Text Fill Brightness", "fillBrightness"), + ("ADBE Text Fill Opacity", "fillOpacity"), + ("ADBE Text Stroke Color", "strokeColor"), + ("ADBE Text Stroke Hue", "strokeHue"), + ("ADBE Text Stroke Saturation", "strokeSaturation"), + ("ADBE Text Stroke Brightness", "strokeBrightness"), + ("ADBE Text Stroke Opacity", "strokeOpacity"), + ("ADBE Text Stroke Width", "strokeWidth"), + ("ADBE Text Tracking Amount", "tracking"), + ("ADBE Text Line Anchor", "lineAnchor"), + ("ADBE Text Line Spacing", "lineSpacing"), + ("ADBE Text Character Offset", "characterOffset"), + ("ADBE Text Character Replace", "characterValue"), + ("ADBE Text Blur", "blur"), + ]; + if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + return Some(k); + } + let n = loose(name); + AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) +} + +/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items +/// first), 0 when none does. +fn menu_command_id(name: &str) -> J { + let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase(); + let n = norm(name); + let specs = effectcraft_engine::command_specs(); + let pick = specs + .iter() + .position(|c| !c.menu.is_empty() && norm(c.label) == n) + .or_else(|| specs.iter().position(|c| norm(c.label) == n)) + .or_else(|| specs.iter().position(|c| c.id == name)); + json!(pick.map(|i| i + 1).unwrap_or(0)) +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js new file mode 100644 index 00000000..4f359926 --- /dev/null +++ b/crates/script/src/prelude.js @@ -0,0 +1,1689 @@ +// EffectCraft scripting: an After Effects-style object model over the engine. +// +// Written from the behaviour the After Effects Scripting Guide documents (object names, +// attributes, methods). Reads go through `__query(kind, json)`; every edit runs an engine command +// through `__exec(id, json)`, so it is undoable and identical to the UI's action. + +var __ecGlobal = this; + +function __call(id, p) { + var r = __exec(id, JSON.stringify(p || {})); + return r === undefined || r === "" ? null : JSON.parse(r); +} +function __get(kind, a) { + return JSON.parse(__query(kind, JSON.stringify(a || {}))); +} +function __err(msg) { + return new Error(msg); +} +function __num(v, what) { + var n = Number(v); + if (typeof v === "boolean" || isNaN(n)) throw __err((what || "value") + " must be a number"); + return n; +} +function __str(v) { + if (v === undefined || v === null) return ""; + if (v instanceof File || v instanceof Folder) return v.fsName; + return String(v); +} + +// ------------------------------------------------------------------ enums + +function __enum(name, keys, base) { + var e = {}; + for (var i = 0; i < keys.length; i++) e[keys[i]] = base + i; + e.__name = name; + e.__keys = keys; + e.__base = base; + return e; +} +function __enumKey(e, v) { + var i = v - e.__base; + return i >= 0 && i < e.__keys.length ? e.__keys[i] : null; +} + +var BlendingMode = __enum("BlendingMode", [ + "NORMAL", "DISSOLVE", "DANCING_DISSOLVE", "DARKEN", "MULTIPLY", "COLOR_BURN", "CLASSIC_COLOR_BURN", "LINEAR_BURN", + "DARKER_COLOR", "ADD", "LIGHTEN", "SCREEN", "COLOR_DODGE", "CLASSIC_COLOR_DODGE", "LINEAR_DODGE", "LIGHTER_COLOR", + "OVERLAY", "SOFT_LIGHT", "HARD_LIGHT", "LINEAR_LIGHT", "VIVID_LIGHT", "PIN_LIGHT", "HARD_MIX", "DIFFERENCE", + "CLASSIC_DIFFERENCE", "EXCLUSION", "SUBTRACT", "DIVIDE", "HUE", "SATURATION", "COLOR", "LUMINOSITY", "STENCIL_ALPHA", + "STENCIL_LUMA", "SILHOUETE_ALPHA", "SILHOUETTE_LUMA", "ALPHA_ADD", "LUMINESCENT_PREMUL"], 5212); +BlendingMode.SILHOUETTE_ALPHA = BlendingMode.SILHOUETE_ALPHA; +var KeyframeInterpolationType = __enum("KeyframeInterpolationType", ["LINEAR", "BEZIER", "HOLD"], 6612); +var TrackMatteType = __enum("TrackMatteType", ["NO_TRACK_MATTE", "ALPHA", "ALPHA_INVERTED", "LUMA", "LUMA_INVERTED"], 5012); +var LightType = __enum("LightType", ["PARALLEL", "SPOT", "POINT", "AMBIENT", "ENVIRONMENT"], 4412); +var ParagraphJustification = __enum("ParagraphJustification", [ + "LEFT_JUSTIFY", "CENTER_JUSTIFY", "RIGHT_JUSTIFY", "FULL_JUSTIFY_LASTLINE_LEFT", "FULL_JUSTIFY_LASTLINE_CENTER", + "FULL_JUSTIFY_LASTLINE_RIGHT", "FULL_JUSTIFY_LASTLINE_FULL", "MULTIPLE_JUSTIFICATIONS"], 7413); +var PropertyValueType = __enum("PropertyValueType", [ + "NO_VALUE", "ThreeD_SPATIAL", "ThreeD", "TwoD_SPATIAL", "TwoD", "OneD", "COLOR", "CUSTOM_VALUE", "MARKER", + "LAYER_INDEX", "MASK_INDEX", "SHAPE", "TEXT_DOCUMENT"], 6412); +var PropertyType = __enum("PropertyType", ["PROPERTY", "INDEXED_GROUP", "NAMED_GROUP"], 6212); +var LayerQuality = __enum("LayerQuality", ["BEST", "DRAFT", "WIREFRAME"], 4612); +var LayerSamplingQuality = __enum("LayerSamplingQuality", ["BILINEAR", "BICUBIC"], 4812); +var FrameBlendingType = __enum("FrameBlendingType", ["NO_FRAME_BLEND", "FRAME_MIX", "PIXEL_MOTION"], 4012); +var AutoOrientType = __enum("AutoOrientType", ["NO_AUTO_ORIENT", "ALONG_PATH", "CAMERA_OR_POINT_OF_INTEREST", "CHARACTERS_TOWARD_CAMERA"], 4212); +var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGHTEN", "DARKEN", "DIFFERENCE"], 6812); +var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012); +var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212); +var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812); +var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); +var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); +var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); + +var __blendNames = {}; +(function () { + var labels = ["Normal", "Dissolve", "Dancing Dissolve", "Darken", "Multiply", "Color Burn", "Classic Color Burn", + "Linear Burn", "Darker Color", "Add", "Lighten", "Screen", "Color Dodge", "Classic Color Dodge", "Linear Dodge", + "Lighter Color", "Overlay", "Soft Light", "Hard Light", "Linear Light", "Vivid Light", "Pin Light", "Hard Mix", + "Difference", "Classic Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity", + "Stencil Alpha", "Stencil Luma", "Silhouette Alpha", "Silhouette Luma", "Alpha Add", "Luminescent Premul"]; + for (var i = 0; i < labels.length; i++) __blendNames[labels[i]] = BlendingMode.__base + i; +})(); +function __blendLabel(v) { + for (var k in __blendNames) if (__blendNames[k] === v) return k; + throw __err("Bad blendingMode value " + v); +} + +var __interpKey = { 6612: "linear", 6613: "bezier", 6614: "hold" }; +var __matteKind = { 5013: "alpha", 5014: "alphaInverted", 5015: "luma", 5016: "lumaInverted" }; +var __matteEnum = { Alpha: 5013, AlphaInverted: 5014, Luma: 5015, LumaInverted: 5016 }; +var __justKey = { 7413: "left", 7414: "center", 7415: "right", 7416: "justifyLeft", 7417: "justifyCenter", 7418: "justifyRight", 7419: "justifyAll" }; + +// ------------------------------------------------------------------ output, $ and dialogs + +function writeLn(s) { __print(__str(s)); } +function write(s) { __print(__str(s)); } +function alert(s) { __print(__str(s)); } +function confirm(s) { __print(__str(s)); return true; } +function prompt(msg, def) { __print(__str(msg)); return def === undefined ? "" : def; } +function clearOutput() {} + +var $ = { + writeln: function () { __print(Array.prototype.slice.call(arguments).map(__str).join("")); }, + write: function () { __print(Array.prototype.slice.call(arguments).map(__str).join("")); }, + sleep: function () {}, + gc: function () {}, + global: __ecGlobal, + level: 0, + engineName: "boa", + get fileName() { return __get("app").scriptName; }, + get os() { return "EffectCraft"; }, + get version() { return "EffectCraft scripting"; }, + get locale() { return "en_US"; }, + evalFile: function () { throw __err("$.evalFile is not supported: scripts can't run scripts"); }, +}; + +// ------------------------------------------------------------------ File / Folder + +function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } + +function File(path) { + if (!(this instanceof File)) return new File(path); + this.__path = path === undefined ? "" : __str(path); + this.__buf = null; + this.__mode = null; + this.__pos = 0; + this.encoding = "UTF-8"; + this.lineFeed = "Unix"; + this.error = ""; +} +File.prototype = { + constructor: File, + get fsName() { return this.__path; }, + get fullName() { return this.__path; }, + get absoluteURI() { return this.__path; }, + get name() { return this.__path.split(/[\/\\]/).pop(); }, + get displayName() { return this.name; }, + get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); }, + get parent() { return new Folder(this.path); }, + get exists() { return JSON.parse(__file("exists", this.__path)) === true; }, + get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); }, + get eof() { return this.__buf === null || this.__pos >= this.__buf.length; }, + toString: function () { return this.__path; }, + open: function (mode) { + mode = (mode || "r").charAt(0).toLowerCase(); + this.__mode = mode; + this.__pos = 0; + if (mode === "r" || mode === "e") { + // Throws when the location is off limits (the scripting file gate). + if (!JSON.parse(__file("access", this.__path))) { + if (mode === "r") { this.error = "File not found"; return false; } + this.__buf = ""; + return true; + } + this.__buf = JSON.parse(__file("read", this.__path)); + } else if (mode === "a") { + this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : ""; + this.__pos = this.__buf.length; + } else { + // Check the gate now, like After Effects refusing to open a file for writing. + JSON.parse(__file("write", this.__path, "")); + this.__buf = ""; + } + return true; + }, + read: function (n) { + if (this.__buf === null) this.open("r"); + if (this.__buf === null) return ""; + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + this.__pos += s.length; + return s; + }, + readln: function () { + if (this.__buf === null) this.open("r"); + if (this.__buf === null) return ""; + var i = this.__buf.indexOf("\n", this.__pos); + var end = i < 0 ? this.__buf.length : i; + var s = this.__buf.substring(this.__pos, end).replace(/\r$/, ""); + this.__pos = i < 0 ? this.__buf.length : i + 1; + return s; + }, + write: function () { + if (this.__mode !== "w" && this.__mode !== "a" && this.__mode !== "e") throw __err("File is not open for writing"); + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + this.__buf = this.__buf.substring(0, this.__pos) + s + this.__buf.substring(this.__pos + s.length); + this.__pos += s.length; + return true; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + seek: function (p) { this.__pos = p; return true; }, + tell: function () { return this.__pos; }, + close: function () { + if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) { + __file("write", this.__path, this.__buf); + } + this.__buf = null; + this.__mode = null; + return true; + }, + remove: function () { return JSON.parse(__file("remove", this.__path)); }, + copy: function (to) { + var data = JSON.parse(__file("read", this.__path)); + __file("write", __str(to), data); + return true; + }, + rename: function () { throw __err("File.rename is not supported"); }, + execute: function () { throw __err("scripts can't execute files"); }, + openDlg: function () { return null; }, + saveDlg: function () { return null; }, +}; +File.openDialog = function () { return null; }; +File.saveDialog = function () { return null; }; +File.decode = function (s) { return decodeURIComponent(s); }; +File.encode = function (s) { return encodeURIComponent(s); }; +File.fs = "EffectCraft"; + +function Folder(path) { + if (!(this instanceof Folder)) return new Folder(path); + this.__path = path === undefined ? "" : __str(path); +} +Folder.prototype = { + constructor: Folder, + get fsName() { return this.__path; }, + get fullName() { return this.__path; }, + get absoluteURI() { return this.__path; }, + get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); }, + get displayName() { return this.name; }, + get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); }, + get parent() { return new Folder(this.path); }, + get exists() { return JSON.parse(__file("isDir", this.__path)) === true; }, + toString: function () { return this.__path; }, + create: function () { return JSON.parse(__file("mkdir", this.__path)); }, + remove: function () { return JSON.parse(__file("remove", this.__path)); }, + getFiles: function (mask) { + var names = JSON.parse(__file("list", this.__path)); + var out = []; + for (var i = 0; i < names.length; i++) { + var n = names[i]; + if (typeof mask === "string" && mask !== "*") { + var re = new RegExp("^" + mask.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i"); + if (!re.test(n.split(/[\/\\]/).pop())) continue; + } + var isDir = JSON.parse(__file("isDir", n)); + var f = isDir ? new Folder(n) : new File(n); + if (typeof mask === "function" && !mask(f)) continue; + out.push(f); + } + return out; + }, + selectDlg: function () { return null; }, +}; +Folder.selectDialog = function () { return null; }; +Object.defineProperty(Folder, "current", { get: function () { var d = __get("app").projectDir; return d ? new Folder(d) : null; } }); +Object.defineProperty(Folder, "temp", { get: function () { return null; } }); +Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); +Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); + +function Socket() { __file("network", ""); } +var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; + +// ------------------------------------------------------------------ helpers + +// Unknown attributes of property groups and layers resolve to child properties, like After +// Effects (`layer.transform.position`, `layer.Effects`, `effect.blurriness`). +var __attr = { + get: function (t, k, r) { + if (typeof k !== "string" || k in t || k.charAt(0) === "_" || k === "toJSON" || k === "then") return Reflect.get(t, k, r); + var c = t.property(k); + return c === null ? undefined : c; + }, +}; + +// 1-based collections with `[n]` access. +var __coll = { + get: function (t, k, r) { + if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(parseInt(k, 10)); + return Reflect.get(t, k, r); + }, +}; + +function KeyframeEase(speed, influence) { + if (!(this instanceof KeyframeEase)) return new KeyframeEase(speed, influence); + this.speed = speed === undefined ? 0 : speed; + this.influence = influence === undefined ? 16.666666667 : influence; +} + +function Shape() { + if (!(this instanceof Shape)) return new Shape(); + this.vertices = []; + this.inTangents = []; + this.outTangents = []; + this.closed = true; + this.featherSegLocs = []; + this.featherRelSegLocs = []; + this.featherRadii = []; + this.featherInterps = []; + this.featherTensions = []; + this.featherTypes = []; + this.featherRelCornerAngles = []; +} + +function MarkerValue(comment, chapter, url, frameTarget, cuePointName, params) { + if (!(this instanceof MarkerValue)) return new MarkerValue(comment, chapter, url, frameTarget, cuePointName, params); + this.comment = comment === undefined ? "" : String(comment); + this.chapter = chapter === undefined ? "" : String(chapter); + this.url = url === undefined ? "" : String(url); + this.frameTarget = frameTarget === undefined ? "" : String(frameTarget); + this.cuePointName = cuePointName === undefined ? "" : String(cuePointName); + this.duration = 0; + this.eventCuePoint = false; + this.label = 0; + this.protectedRegion = false; + this.__params = []; + if (params) this.setParameters(params); +} +MarkerValue.prototype.getParameters = function () { + var o = {}; + for (var i = 0; i < this.__params.length; i++) o[this.__params[i][0]] = this.__params[i][1]; + return o; +}; +MarkerValue.prototype.setParameters = function (o) { + this.__params = []; + for (var k in o) this.__params.push([k, String(o[k])]); +}; + +// ------------------------------------------------------------------ TextDocument + +// `layer.setText` keys of each TextDocument attribute. +var __textKeys = { + font: "font", fontStyle: "style", fontSize: "size", fillColor: "fill", strokeColor: "stroke", strokeWidth: "strokeWidth", + applyFill: "applyFill", applyStroke: "applyStroke", strokeOverFill: "strokeOverFill", tracking: "tracking", + baselineShift: "baselineShift", horizontalScale: "hScale", verticalScale: "vScale", fauxBold: "fauxBold", + fauxItalic: "fauxItalic", allCaps: "allCaps", smallCaps: "smallCaps", tsume: "tsume", superscript: "superscript", + subscript: "subscript", ligature: "ligatures", startIndent: "indentLeft", endIndent: "indentRight", + firstLineIndent: "indentFirst", spaceBefore: "spaceBefore", spaceAfter: "spaceAfter", +}; + +function TextDocument(text) { + if (!(this instanceof TextDocument)) return new TextDocument(text); + this.__base = null; + this.__changed = {}; + this.__text = text === undefined ? "" : String(text); + this.__textSet = text !== undefined; +} +TextDocument.__from = function (doc) { + var t = new TextDocument(); + t.__base = doc; + t.__text = doc.text; + t.__textSet = false; + return t; +}; +TextDocument.prototype = { + constructor: TextDocument, + get text() { return this.__text; }, + set text(v) { this.__text = String(v); this.__textSet = true; }, + __style: function (k, d) { + if (k in this.__changed) return this.__changed[k]; + if (this.__base && this.__base.first && k in this.__base.first) return this.__base.first[k]; + if (this.__base && this.__base.para && k in this.__base.para) return this.__base.para[k]; + return d; + }, + __set: function (k, v) { this.__changed[k] = v; }, + get font() { return this.__style("font", "Inter"); }, + set font(v) { this.__set("font", String(v)); }, + get fontFamily() { return this.font; }, + get fontStyle() { return this.__style("style", "Regular"); }, + set fontStyle(v) { this.__set("style", String(v)); }, + get fontSize() { return this.__style("size", 72); }, + set fontSize(v) { this.__set("size", __num(v, "fontSize")); }, + get fillColor() { var c = this.__style("fill", [1, 1, 1, 1]); return [c[0], c[1], c[2]]; }, + set fillColor(v) { this.__set("fill", [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]); }, + get strokeColor() { var c = this.__style("stroke", [0, 0, 0, 1]); return [c[0], c[1], c[2]]; }, + set strokeColor(v) { this.__set("stroke", [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]); }, + get strokeWidth() { return this.__style("strokeWidth", 0); }, + set strokeWidth(v) { this.__set("strokeWidth", __num(v, "strokeWidth")); }, + get applyFill() { return this.__style("applyFill", true); }, + set applyFill(v) { this.__set("applyFill", !!v); }, + get applyStroke() { return this.__style("applyStroke", false); }, + set applyStroke(v) { this.__set("applyStroke", !!v); }, + get strokeOverFill() { return "strokeOverFill" in this.__changed ? this.__changed.strokeOverFill : (this.__base ? this.__base.stroke_over_fill : true); }, + set strokeOverFill(v) { this.__set("strokeOverFill", !!v); }, + get tracking() { return this.__style("tracking", 0); }, + set tracking(v) { this.__set("tracking", __num(v, "tracking")); }, + get leading() { var l = this.__style("leading", "auto"); return l === "auto" ? this.fontSize * 1.2 : l; }, + set leading(v) { this.__set("leading", __num(v, "leading")); }, + get autoLeading() { return this.__style("leading", "auto") === "auto"; }, + set autoLeading(v) { if (v) this.__set("leading", "auto"); }, + get justification() { + var j = this.__style("justify", "left"); + for (var k in __justKey) if (__justKey[k] === j) return Number(k); + return ParagraphJustification.LEFT_JUSTIFY; + }, + set justification(v) { + var k = __justKey[v]; + if (!k) throw __err("Bad justification value " + v); + this.__set("justify", k); + }, + get baselineShift() { return this.__style("baselineShift", 0); }, + set baselineShift(v) { this.__set("baselineShift", __num(v)); }, + get horizontalScale() { return this.__style("hScale", 100) / 100; }, + set horizontalScale(v) { this.__set("hScale", __num(v) * 100); }, + get verticalScale() { return this.__style("vScale", 100) / 100; }, + set verticalScale(v) { this.__set("vScale", __num(v) * 100); }, + get fauxBold() { return this.__style("fauxBold", false); }, + set fauxBold(v) { this.__set("fauxBold", !!v); }, + get fauxItalic() { return this.__style("fauxItalic", false); }, + set fauxItalic(v) { this.__set("fauxItalic", !!v); }, + get allCaps() { return this.__style("allCaps", false); }, + set allCaps(v) { this.__set("allCaps", !!v); }, + get smallCaps() { return this.__style("smallCaps", false); }, + set smallCaps(v) { this.__set("smallCaps", !!v); }, + get superscript() { return this.__style("baseline", "normal") === "superscript"; }, + set superscript(v) { this.__set("superscript", !!v); }, + get subscript() { return this.__style("baseline", "normal") === "subscript"; }, + set subscript(v) { this.__set("subscript", !!v); }, + get tsume() { return this.__style("tsume", 0); }, + set tsume(v) { this.__set("tsume", __num(v)); }, + get boxText() { return !!(this.__base && this.__base.box_size); }, + get pointText() { return !this.boxText; }, + get boxTextSize() { return this.__base && this.__base.box_size ? this.__base.box_size.slice() : undefined; }, + set boxTextSize(v) { + var p = this.boxTextPos || [0, 0]; + this.__set("box", [p[0], p[1], v[0], v[1]]); + }, + get boxTextPos() { return this.__base && this.__base.box_size ? this.__base.box_pos.slice() : undefined; }, + get startIndent() { return this.__style("indentLeft", 0); }, + set startIndent(v) { this.__set("indentLeft", __num(v)); }, + get endIndent() { return this.__style("indentRight", 0); }, + set endIndent(v) { this.__set("indentRight", __num(v)); }, + get firstLineIndent() { return this.__style("indentFirst", 0); }, + set firstLineIndent(v) { this.__set("indentFirst", __num(v)); }, + get spaceBefore() { return this.__style("spaceBefore", 0); }, + set spaceBefore(v) { this.__set("spaceBefore", __num(v)); }, + get spaceAfter() { return this.__style("spaceAfter", 0); }, + set spaceAfter(v) { this.__set("spaceAfter", __num(v)); }, + resetCharStyle: function () { + var d = { font: "Inter", style: "Regular", size: 72, fill: [1, 1, 1, 1], stroke: [0, 0, 0, 1], strokeWidth: 0, applyFill: true, + applyStroke: false, tracking: 0, leading: "auto", baselineShift: 0, hScale: 100, vScale: 100, tsume: 0, fauxBold: false, + fauxItalic: false, allCaps: false, smallCaps: false }; + for (var k in d) this.__changed[k] = d[k]; + }, + resetParagraphStyle: function () { + var d = { justify: "left", indentLeft: 0, indentRight: 0, indentFirst: 0, spaceBefore: 0, spaceAfter: 0 }; + for (var k in d) this.__changed[k] = d[k]; + }, + toString: function () { return this.__text; }, + // `layer.setText` attributes to apply. + __attrs: function () { + var a = {}; + for (var k in this.__changed) a[k] = this.__changed[k]; + if (this.__textSet || !this.__base || this.__text !== this.__base.text) a.text = this.__text; + return a; + }, +}; + +// ------------------------------------------------------------------ properties + +function __propOf(layer, info) { + if (!info) return null; + if (info.isLayer) return layer; + if (info.kind === "group") return info.isMask ? new MaskPropertyGroup(layer, info.uid) : new PropertyGroup(layer, info.uid); + return new Property(layer, info.uid); +} + +function PropertyBase() {} +PropertyBase.prototype = { + __info: function () { return __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }, + __ref: function (p) { + p = p || {}; + p.comp = this.__layer.__comp; + p.layer = this.__layer.__id; + p.prop = this.__uid; + return p; + }, + get name() { return this.__info().name; }, + set name(v) { + var i = this.__info(); + if (!i.instance) throw __err("Can not set the name of \"" + i.name + "\": it is not an indexed group member"); + __call("prop.renameGroup", this.__ref({ name: String(v) })); + }, + get matchName() { return this.__info().matchName; }, + get propertyIndex() { return this.__info().index; }, + get propertyDepth() { return this.__info().depth; }, + get propertyType() { return PropertyType[this.__info().propertyType]; }, + get parentProperty() { + var i = this.__info(); + if (i.depth <= 1) return this.__layer; + return __propOf(this.__layer, __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: i.parent })); + }, + get isModified() { return !!this.__info().isModified; }, + get canSetEnabled() { return !!this.__info().canSetEnabled; }, + get enabled() { return this.__info().enabled !== false; }, + set enabled(v) { + if (!this.canSetEnabled) throw __err("Can not set enabled on \"" + this.name + "\""); + __call("prop.setGroupEnabled", this.__ref({ value: !!v })); + }, + get active() { return this.enabled && this.__layer.enabled; }, + get elided() { return false; }, + get isEffect() { return !!this.__info().isEffect; }, + get isMask() { return !!this.__info().isMask; }, + get selected() { + var c = __get("item", { id: this.__layer.__comp }).selectedProperties; + for (var i = 0; i < c.length; i++) if (c[i][0] === this.__layer.__id && c[i][1] === this.__uid) return true; + return false; + }, + set selected(v) { + if (v) __call("prop.select", { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid, add: true }); + }, + propertyGroup: function (n) { + var p = this; + n = n === undefined ? 1 : n; + for (var i = 0; i < n && p; i++) p = p.parentProperty; + return p || null; + }, + remove: function () { __call("prop.removeGroup", this.__ref()); }, + moveTo: function (i) { __call("prop.moveGroup", this.__ref({ index: __num(i, "index") })); }, + duplicate: function () { + var r = __call("prop.duplicateGroup", this.__ref()); + return __propOf(this.__layer, __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: r.prop })); + }, + toString: function () { return "[object " + this.__kind + "]"; }, +}; + +function PropertyGroup(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "PropertyGroup"; + return new Proxy(this, __attr); +} +PropertyGroup.prototype = Object.create(PropertyBase.prototype); +PropertyGroup.prototype.constructor = PropertyGroup; +Object.defineProperty(PropertyGroup.prototype, "numProperties", { get: function () { return this.__info().numProperties; } }); +PropertyGroup.prototype.property = function (key) { + if (key === undefined || key === null) throw __err("property(): missing name or index"); + var k = typeof key === "number" ? key : String(key); + return __propOf(this.__layer, __get("child", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, key: k })); +}; +PropertyGroup.prototype.canAddProperty = function (name) { + var r = __get("resolveAdd", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, name: String(name) }); + return !r.error; +}; +PropertyGroup.prototype.addProperty = function (name) { + var L = this.__layer; + var r = __get("resolveAdd", { comp: L.__comp, layer: L.__id, uid: this.__uid, name: String(name) }); + if (r.error) throw __err(r.error); + var uid; + if (r.kind === "effect") uid = __call("effect.apply", { comp: L.__comp, layers: [L.__id], effect: r.effect }).effects[0]; + else if (r.kind === "mask") uid = __call("layer.addMask", { comp: L.__comp, layer: L.__id }).mask; + else if (r.kind === "shape") { + var p = { comp: L.__comp, layer: L.__id, kind: r.shape }; + if (r.group !== null && r.group !== undefined) p.group = r.group; + uid = __call("layer.addShapeItem", p).uid; + } else if (r.kind === "animator") { + uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; + } else if (r.kind === "animatorProperty") { + var before = this.numProperties; + __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(before + 1); + } else if (r.kind === "selector") { + var n0 = this.numProperties; + __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); + return this.property(n0 + 1); + } else throw __err("Can not add \"" + name + "\" here"); + return __propOf(L, __get("node", { comp: L.__comp, layer: L.__id, uid: uid })); +}; + +function MaskPropertyGroup(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "MaskPropertyGroup"; + return new Proxy(this, __attr); +} +MaskPropertyGroup.prototype = Object.create(PropertyGroup.prototype); +MaskPropertyGroup.prototype.constructor = MaskPropertyGroup; +(function () { + var modes = { "None": 6812, "Add": 6813, "Subtract": 6814, "Intersect": 6815, "Lighten": 6816, "Darken": 6817, "Difference": 6818 }; + function mask(o) { return { comp: o.__layer.__comp, layer: o.__layer.__id, mask: o.__uid }; } + Object.defineProperty(MaskPropertyGroup.prototype, "maskMode", { + get: function () { return modes[this.__info().maskMode]; }, + set: function (v) { + var p = mask(this); + for (var k in modes) if (modes[k] === v) p.mode = k; + if (!p.mode) throw __err("Bad MaskMode value " + v); + __call("layer.mask.mode", p); + }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "inverted", { + get: function () { return !!this.__info().inverted; }, + set: function (v) { var p = mask(this); p.value = !!v; __call("layer.mask.invert", p); }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "locked", { + get: function () { return !!this.__info().locked; }, + set: function (v) { var p = mask(this); p.value = !!v; __call("layer.mask.lock", p); }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "color", { get: function () { return this.__info().color; } }); + MaskPropertyGroup.prototype.remove = function () { __call("layer.mask.remove", mask(this)); }; +})(); + +function Property(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "Property"; + return new Proxy(this, __attr); +} +Property.prototype = Object.create(PropertyBase.prototype); +Property.prototype.constructor = Property; +Property.prototype.property = function () { return null; }; +(function () { + var P = Property.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + def("propertyValueType", function () { return PropertyValueType[this.__info().valueType]; }); + def("value", function () { return this.valueAtTime(this.__layer.containingComp.time, false); }); + def("numKeys", function () { return this.__info().numKeys; }); + def("expression", function () { return this.__info().expression; }, function (v) { + if (!this.canSetExpression) throw __err("Can not set an expression on \"" + this.name + "\""); + __call("prop.setExpression", this.__ref({ expression: String(v) })); + }); + def("expressionEnabled", function () { return !!this.__info().expressionEnabled; }, function (v) { + __call("prop.setExpression", this.__ref({ enabled: !!v })); + }); + def("expressionError", function () { + var e = this.expression; + return e ? __get("exprError", { code: e }) : ""; + }); + def("canSetExpression", function () { return !!this.__info().canSetExpression; }); + def("canVaryOverTime", function () { return !!this.__info().canVaryOverTime; }); + def("isTimeVarying", function () { var i = this.__info(); return i.numKeys > 0 || !!i.expressionEnabled; }); + def("isSpatial", function () { return !!this.__info().spatial; }); + def("unitsText", function () { return this.__info().ui; }); + def("hasMin", function () { return this.__info().min !== undefined; }); + def("hasMax", function () { return this.__info().max !== undefined; }); + def("minValue", function () { var i = this.__info(); if (i.min === undefined) throw __err("property has no minimum"); return i.min; }); + def("maxValue", function () { var i = this.__info(); if (i.max === undefined) throw __err("property has no maximum"); return i.max; }); + def("dimensionsSeparated", function () { return !!this.__info().separated; }, function (v) { + __call("prop.separateDimensions", { comp: this.__layer.__comp, layer: this.__layer.__id, value: !!v }); + }); + def("isSeparationFollower", function () { return !!this.__info().separationFollower; }); + def("isSeparationLeader", function () { return this.__info().matchId === "position" && this.__info().depth === 2; }); + def("separationDimension", function () { var m = this.__info().matchId; return m === "positionY" ? 1 : m === "positionZ" ? 2 : 0; }); + def("separationLeader", function () { return this.isSeparationFollower ? this.parentProperty.property("ADBE Position") : null; }); + def("selectedKeys", function () { + var k = this.__keys(), out = []; + for (var i = 0; i < k.length; i++) if (k[i].selected) out.push(i + 1); + return out; + }); + def("isDropdownEffect", function () { return false; }); + + P.getSeparationFollower = function (dim) { + return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); + }; + P.__keys = function () { return __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }; + P.__key = function (i) { + var k = this.__keys(); + i = __num(i, "keyIndex"); + if (i < 1 || i > k.length || Math.floor(i) !== i) throw __err("keyIndex " + i + " is out of range 1.." + k.length); + return k[i - 1]; + }; + // Script value → engine JSON for `prop.set` / `prop.addKey`. + P.__toEngine = function (v, info, time) { + var t = info.valueType; + if (t === "TEXT_DOCUMENT") { + var td = v instanceof TextDocument ? v : new TextDocument(String(v)); + return __get("textdoc", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: time, attrs: td.__attrs() }); + } + if (t === "SHAPE") { + if (!v || !v.vertices) throw __err("value must be a Shape"); + var n = v.vertices.length; + function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a && a[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; } + return { vertices: fit(v.vertices), in_tangents: fit(v.inTangents), out_tangents: fit(v.outTangents), closed: v.closed !== false }; + } + if (t === "LAYER_INDEX") { + var idx = v && v.__id !== undefined ? v.index : __num(v); + if (!idx) return null; + return this.__layer.containingComp.layer(idx).__id; + } + if (info.type === "enum") return __num(v) - 1; + if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; + if (info.type === "color") { + if (!(v instanceof Array) || v.length < 3) throw __err("value must be an array of 3 or 4 numbers"); + return [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]; + } + if (info.dims > 1) { + if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers"); + var o = []; + for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1]))); + return o; + } + if (v instanceof Array) throw __err("value must be a number, not an array"); + return __num(v); + }; + P.__fromEngine = function (v, info) { + if (info.valueType === "TEXT_DOCUMENT") return TextDocument.__from(v); + if (info.valueType === "SHAPE") { + var s = new Shape(); + s.vertices = v.vertices; + s.inTangents = v.inTangents; + s.outTangents = v.outTangents; + s.closed = v.closed; + return s; + } + if (info.type === "color" && v instanceof Array) return v.slice(); + return v; + }; + P.valueAtTime = function (t, preExpression) { + var info = this.__info(); + var v = __get("value", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: __num(t, "time"), pre: !!preExpression }); + return this.__fromEngine(v, info); + }; + P.setValue = function (v) { + var info = this.__info(); + if (info.numKeys > 0) throw __err("Can not use setValue on \"" + info.name + "\": it has keyframes (use setValueAtTime or setValueAtKey)"); + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + __call("prop.set", this.__ref({ value: this.__toEngine(v, info) })); + }; + P.setValueAtTime = function (t, v) { + var info = this.__info(); + t = __num(t, "time"); + __call("prop.addKey", this.__ref({ time: this.__layer.__layerTime(t), value: this.__toEngine(v, info, t) })); + }; + P.setValuesAtTimes = function (ts, vs) { + if (!(ts instanceof Array) || !(vs instanceof Array) || ts.length !== vs.length) throw __err("setValuesAtTimes: times and values must be arrays of the same length"); + for (var i = 0; i < ts.length; i++) this.setValueAtTime(ts[i], vs[i]); + }; + P.setValueAtKey = function (i, v) { + var k = this.__key(i), info = this.__info(); + __call("keys.set", this.__ref({ time: k.layerTime, value: this.__toEngine(v, info, k.time) })); + }; + P.addKey = function (t) { + t = __num(t, "time"); + __call("prop.addKey", this.__ref({ time: this.__layer.__layerTime(t) })); + return this.nearestKeyIndex(t); + }; + P.nearestKeyIndex = function (t) { + var k = this.__keys(), best = 0, d = Infinity; + if (!k.length) throw __err("\"" + this.name + "\" has no keyframes"); + for (var i = 0; i < k.length; i++) { + var e = Math.abs(k[i].time - t); + if (e < d) { d = e; best = i + 1; } + } + return best; + }; + P.keyTime = function (i) { return this.__key(i).time; }; + P.keyValue = function (i) { return this.__fromEngine(this.__key(i).value, this.__info()); }; + // Select key `i` (only) so the selection-based keyframe commands act on it. + P.__select = function (i) { + var k = this.__key(i); + __call("keys.select", { comp: this.__layer.__comp, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] }); + return k; + }; + P.removeKey = function (i) { + this.__select(i); + __call("keys.delete", { comp: this.__layer.__comp }); + }; + P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; }; + P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; }; + P.isInterpolationTypeValid = function (t) { return t === 6612 || t === 6613 || t === 6614; }; + P.setInterpolationTypeAtKey = function (i, inType, outType) { + if (outType === undefined) outType = inType; + if (!__interpKey[inType] || !__interpKey[outType]) throw __err("Bad KeyframeInterpolationType"); + this.__select(i); + __call("keys.interpolation", { comp: this.__layer.__comp, "in": __interpKey[inType], out: __interpKey[outType] }); + }; + P.keyInTemporalEase = function (i) { return this.__key(i).inEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; + P.keyOutTemporalEase = function (i) { return this.__key(i).outEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; + P.setTemporalEaseAtKey = function (i, inEase, outEase) { + if (outEase === undefined) outEase = inEase; + if (!(inEase instanceof Array) || !(outEase instanceof Array)) throw __err("setTemporalEaseAtKey: eases must be arrays of KeyframeEase"); + this.__select(i); + function sp(a) { return a.map(function (e) { return __num(e.speed, "speed"); }); } + function inf(a) { + return a.map(function (e) { + var x = __num(e.influence, "influence"); + if (x < 0.1 || x > 100) throw __err("influence must be between 0.1 and 100"); + return x; + }); + } + __call("keys.velocity", { comp: this.__layer.__comp, inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) }); + }; + P.keyTemporalContinuous = function (i) { return !!this.__key(i).continuous; }; + P.setTemporalContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, continuous: !!b }); }; + P.keyTemporalAutoBezier = function (i) { return !!this.__key(i).autoBezier; }; + P.setTemporalAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, autoBezier: !!b }); }; + P.keyRoving = function (i) { return !!this.__key(i).roving; }; + P.setRovingAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, roving: !!b }); }; + P.keySpatialInTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); }; + P.keySpatialOutTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); }; + P.keySpatialContinuous = function (i) { return !!this.__key(i).spatialContinuous; }; + P.keySpatialAutoBezier = function (i) { return !!this.__key(i).spatialAutoBezier; }; + P.setSpatialTangentsAtKey = function (i, inT, outT) { + var k = this.__key(i); + __call("keys.setSpatialTangents", this.__ref({ time: k.layerTime, "in": inT, out: outT === undefined ? inT : outT })); + }; + P.setSpatialContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "continuousBezier" : "bezier" }); }; + P.setSpatialAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "autoBezier" : "bezier" }); }; + P.keySelected = function (i) { return !!this.__key(i).selected; }; + P.setSelectedAtKey = function (i, b) { + var k = this.__key(i); + if (b) { + __call("keys.select", { comp: this.__layer.__comp, add: true, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] }); + } else { + var keep = [], all = this.__keys(); + for (var j = 0; j < all.length; j++) if (all[j].selected && j !== i - 1) keep.push({ layer: this.__layer.__id, prop: this.__uid, time: all[j].layerTime }); + __call("keys.select", { comp: this.__layer.__comp, keys: keep }); + } + }; + P.keyLabel = function () { return 0; }; + P.setLabelAtKey = function () {}; + P.addToMotionGraphicsTemplate = function () { return false; }; + P.canAddToMotionGraphicsTemplate = function () { return false; }; +})(); + +// Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. +function MarkerProperty(comp, layer) { + this.__compId = comp; + this.__layerRef = layer; + this.__kind = "Property"; +} +MarkerProperty.prototype = { + constructor: MarkerProperty, + __p: function (o) { + o = o || {}; + o.comp = this.__compId; + if (this.__layerRef) o.layer = this.__layerRef.__id; + return o; + }, + __list: function () { return __get("markers", this.__p()); }, + get name() { return "Marker"; }, + get matchName() { return "ADBE Marker"; }, + get propertyValueType() { return PropertyValueType.MARKER; }, + get propertyType() { return PropertyType.PROPERTY; }, + get numKeys() { return this.__list().length; }, + get value() { return null; }, + get canSetExpression() { return false; }, + get canVaryOverTime() { return true; }, + get isTimeVarying() { return this.numKeys > 0; }, + get parentProperty() { return this.__layerRef || null; }, + __at: function (i) { + var l = this.__list(); + if (i < 1 || i > l.length) throw __err("marker index " + i + " is out of range 1.." + l.length); + return l[i - 1]; + }, + keyTime: function (i) { return this.__at(i).time; }, + keyValue: function (i) { + var m = this.__at(i); + var v = new MarkerValue(m.comment, m.chapter, m.url, m.frameTarget, m.cuePointName); + v.duration = m.duration; + v.eventCuePoint = m.eventCuePoint; + v.label = m.label; + v.protectedRegion = m.protectedRegion; + v.__params = m.params || []; + return v; + }, + nearestKeyIndex: function (t) { + var l = this.__list(), best = 0, d = Infinity; + for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < d) { d = Math.abs(l[i].time - t); best = i + 1; } + if (!best) throw __err("there are no markers"); + return best; + }, + setValueAtTime: function (t, mv) { + t = __num(t, "time"); + if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue"); + var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 }); + p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; + var l = this.__list(), at = -1; + for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i; + if (at >= 0) p.index = at; + else p["new"] = true; + __call("markers.set", p); + }, + setValueAtKey: function (i, mv) { this.setValueAtTime(this.keyTime(i), mv); }, + removeKey: function (i) { this.__at(i); __call("markers.delete", this.__p({ index: i - 1 })); }, + toString: function () { return "[object Property]"; }, +}; + +// ------------------------------------------------------------------ layers + +function __layer(compId, id) { + if (id === null || id === undefined) return null; + var i = __get("layer", { comp: compId, layer: id }); + switch (i.kind) { + case "text": return new TextLayer(compId, id); + case "shape": return new ShapeLayer(compId, id); + case "camera": return new CameraLayer(compId, id); + case "light": return new LightLayer(compId, id); + default: return new AVLayer(compId, id); + } +} + +function Layer() {} +Layer.prototype = { + __init: function (comp, id, kind) { + this.__comp = comp; + this.__id = id; + this.__kind = kind; + return new Proxy(this, __attr); + }, + __info: function () { return __get("layer", { comp: this.__comp, layer: this.__id }); }, + __p: function (o) { + o = o || {}; + o.comp = this.__comp; + o.layers = [this.__id]; + return o; + }, + __p1: function (o) { + o = o || {}; + o.comp = this.__comp; + o.layer = this.__id; + return o; + }, + __switch: function (sw, v) { __call("layer.setSwitch", this.__p({ "switch": sw, value: !!v })); }, + // Comp seconds → layer seconds (keyframe times are layer time). + __layerTime: function (t) { + var i = this.__info(); + return (t - i.startTime) * 100 / (i.stretch || 100); + }, + toString: function () { return "[object " + this.__kind + "]"; }, + get id() { return this.__id; }, + get name() { return this.__info().name; }, + set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); }, + get index() { return this.__info().index; }, + get matchName() { return this.__info().matchName; }, + get containingComp() { return new CompItem(this.__comp); }, + get propertyDepth() { return 0; }, + get propertyType() { return PropertyType.INDEXED_GROUP; }, + get parentProperty() { return null; }, + get isModified() { return false; }, + get elided() { return false; }, + get isEffect() { return false; }, + get isMask() { return false; }, + get canSetEnabled() { return true; }, + get enabled() { return this.__info().enabled; }, + set enabled(v) { this.__switch("video", v); }, + get active() { return this.__info().active; }, + get solo() { return this.__info().solo; }, + set solo(v) { this.__switch("solo", v); }, + get locked() { return this.__info().locked; }, + set locked(v) { this.__switch("lock", v); }, + get shy() { return this.__info().shy; }, + set shy(v) { this.__switch("shy", v); }, + get audioEnabled() { return this.__info().audioEnabled; }, + set audioEnabled(v) { this.__switch("audio", v); }, + get hasVideo() { return this.__info().hasVideo; }, + get hasAudio() { return this.__info().hasAudio; }, + get audioActive() { var i = this.__info(); return i.hasAudio && i.audioEnabled; }, + get nullLayer() { return this.__info().nullLayer; }, + get isNameSet() { return true; }, + get inPoint() { return this.__info().inPoint; }, + set inPoint(v) { __call("layer.timing", this.__p({ "in": __num(v, "inPoint") })); }, + get outPoint() { return this.__info().outPoint; }, + set outPoint(v) { __call("layer.timing", this.__p({ out: __num(v, "outPoint") })); }, + get startTime() { return this.__info().startTime; }, + set startTime(v) { __call("layer.timing", this.__p({ start: __num(v, "startTime") })); }, + get stretch() { return this.__info().stretch; }, + set stretch(v) { __call("layer.timeStretch", this.__p({ percent: __num(v, "stretch") })); }, + get time() { return this.__info().time; }, + get label() { return this.__info().label; }, + set label(v) { + var names = __get("labels"); + var n = names[__num(v, "label")]; + if (n === undefined) throw __err("label must be 0-16"); + __call("edit.label", this.__p({ label: n })); + }, + get comment() { return this.__info().comment; }, + get selected() { return this.__info().selected; }, + set selected(v) { + var sel = this.containingComp.__info().selectedLayers; + var on = sel.indexOf(this.__id) >= 0; + if (!!v !== on) __call("layer.select", { comp: this.__comp, layers: [this.__id], toggle: true }); + }, + get selectedProperties() { + var c = this.containingComp.__info().selectedProperties, out = []; + for (var i = 0; i < c.length; i++) if (c[i][0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: c[i][1] }))); + return out; + }, + get parent() { var p = this.__info().parent; return p === null ? null : __layer(this.__comp, p); }, + set parent(v) { __call("layer.setParent", this.__p({ parent: v ? v.__id : null })); }, + setParentWithJump: function (v) { __call("layer.setParent", this.__p({ parent: v ? v.__id : null, compensate: false })); }, + get autoOrient() { return AutoOrientType[{ Off: "NO_AUTO_ORIENT", AlongPath: "ALONG_PATH", TowardsCamera: "CHARACTERS_TOWARD_CAMERA", TowardsPointOfInterest: "CAMERA_OR_POINT_OF_INTEREST" }[this.__info().autoOrient]]; }, + set autoOrient(v) { + var m = { 4212: "off", 4213: "alongPath", 4214: "towardsPointOfInterest", 4215: "towardsCamera" }[v]; + if (!m) throw __err("Bad AutoOrientType value " + v); + __call("layer.autoOrient", this.__p({ mode: m })); + }, + get marker() { return new MarkerProperty(this.__comp, this); }, + get numProperties() { return __get("node", { comp: this.__comp, layer: this.__id, uid: 0 }).numProperties; }, + property: function (key) { + if (key === undefined || key === null) throw __err("property(): missing name or index"); + if (key === "Marker" || key === "ADBE Marker" || key === "marker") return this.marker; + var k = typeof key === "number" ? key : String(key); + return __propOf(this, __get("child", { comp: this.__comp, layer: this.__id, uid: 0, key: k })); + }, + propertyGroup: function () { return null; }, + canAddProperty: function () { return false; }, + addProperty: function (n) { throw __err("Can not add \"" + n + "\" to a layer: use a property group (Effects, Masks, Contents)"); }, + remove: function () { __call("edit.clear", this.__p()); }, + duplicate: function () { + var before = this.containingComp.__info().layers; + __call("edit.duplicate", this.__p()); + var after = this.containingComp.__info().layers; + for (var i = 0; i < after.length; i++) if (before.indexOf(after[i]) < 0) return __layer(this.__comp, after[i]); + return null; + }, + copyToComp: function (comp) { + var c = __call("edit.copy", this.__p()); + var before = comp.__info().layers; + __call("comp.open", { comp: comp.__id }); + __call("edit.paste", { comp: comp.__id }); + return c; + }, + moveToBeginning: function () { __call("layer.arrange", this.__p({ to: "front" })); }, + moveToEnd: function () { __call("layer.arrange", this.__p({ to: "back" })); }, + moveBefore: function (other) { + var me = this.index, to = other.index; + __call("layer.arrange", this.__p({ to: "index", index: me < to ? to - 1 : to })); + }, + moveAfter: function (other) { + var me = this.index, to = other.index; + __call("layer.arrange", this.__p({ to: "index", index: me < to ? to : to + 1 })); + }, + activeAtTime: function (t) { var i = this.__info(); return i.enabled && t >= i.inPoint && t < i.outPoint; }, + applyPreset: function (f) { __call("anim.applyPreset", this.__p({ path: __str(f) })); }, +}; + +function AVLayer(comp, id) { return this.__init(comp, id, "AVLayer"); } +AVLayer.prototype = Object.create(Layer.prototype); +AVLayer.prototype.constructor = AVLayer; +(function () { + var P = AVLayer.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + def("source", function () { return __item(this.__info().source); }); + def("width", function () { return this.__info().width; }); + def("height", function () { return this.__info().height; }); + def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); }); + def("adjustmentLayer", function () { return this.__info().adjustmentLayer; }, function (v) { this.__switch("adjustment", v); }); + def("guideLayer", function () { return this.__info().guideLayer; }, function (v) { this.__switch("guide", v); }); + def("motionBlur", function () { return this.__info().motionBlur; }, function (v) { this.__switch("motionBlur", v); }); + def("effectsActive", function () { return this.__info().effectsActive; }, function (v) { this.__switch("fx", v); }); + def("collapseTransformation", function () { return this.__info().collapse; }, function (v) { this.__switch("collapse", v); }); + def("preserveTransparency", function () { return this.__info().preserveTransparency; }, function (v) { this.__switch("preserveTransparency", v); }); + def("quality", function () { return LayerQuality[this.__info().quality.toUpperCase()]; }, function (v) { + var q = __enumKey(LayerQuality, v); + if (!q) throw __err("Bad LayerQuality value " + v); + __call("layer.quality", this.__p({ quality: q.toLowerCase() })); + }); + def("samplingQuality", function () { return LayerSamplingQuality[this.__info().samplingQuality.toUpperCase()]; }, function (v) { + var q = __enumKey(LayerSamplingQuality, v); + if (!q) throw __err("Bad LayerSamplingQuality value " + v); + __call("layer.sampling", this.__p({ sampling: q.toLowerCase() })); + }); + def("frameBlendingType", function () { return { Off: 4012, FrameMix: 4013, PixelMotion: 4014 }[this.__info().frameBlending]; }, function (v) { + var m = { 4012: "off", 4013: "frameMix", 4014: "pixelMotion" }[v]; + if (!m) throw __err("Bad FrameBlendingType value " + v); + __call("layer.frameBlending", this.__p({ mode: m })); + }); + def("frameBlending", function () { return this.__info().frameBlending !== "Off"; }); + def("blendingMode", function () { return __blendNames[this.__info().blendingMode]; }, function (v) { + __call("layer.setBlendMode", this.__p({ mode: __blendLabel(v) })); + }); + def("trackMatteType", function () { + var m = this.__info().trackMatte; + return m ? __matteEnum[m.kind] : TrackMatteType.NO_TRACK_MATTE; + }, function (v) { + if (v === TrackMatteType.NO_TRACK_MATTE) { __call("layer.trackMatte", this.__p({ op: "none" })); return; } + var k = __matteKind[v]; + if (!k) throw __err("Bad TrackMatteType value " + v); + var m = this.__info().trackMatte; + if (m) __call("layer.setTrackMatte", this.__p1({ matte: m.layer, kind: k })); + else __call("layer.trackMatte", this.__p({ op: k })); + }); + def("trackMatteLayer", function () { var m = this.__info().trackMatte; return m ? __layer(this.__comp, m.layer) : null; }); + def("hasTrackMatte", function () { return !!this.__info().trackMatte; }); + def("isTrackMatte", function () { return this.__info().isTrackMatte; }); + def("timeRemapEnabled", function () { return this.__info().timeRemapEnabled; }, function (v) { + __call("layer.enableTimeRemap", this.__p({ value: !!v })); + }); + def("canSetTimeRemapEnabled", function () { var s = this.source; return !!s && !(s instanceof FootageItem && s.mainSource.isStill); }); + def("canSetCollapseTransformation", function () { return true; }); + def("isNameFromSource", function () { var s = this.source; return !!s && s.name === this.name; }); + def("environmentLayer", function () { return false; }); + P.setTrackMatte = function (layer, type) { + if (!layer || type === TrackMatteType.NO_TRACK_MATTE) { this.removeTrackMatte(); return; } + var k = __matteKind[type]; + if (!k) throw __err("Bad TrackMatteType value " + type); + __call("layer.setTrackMatte", this.__p1({ matte: layer.__id, kind: k })); + }; + P.removeTrackMatte = function () { __call("layer.setTrackMatte", this.__p1({ matte: null })); }; + P.sourceRectAtTime = function (t, extents) { + return __get("sourceRect", { comp: this.__comp, layer: this.__id, time: __num(t, "time"), extents: !!extents }); + }; + P.sourcePointToComp = function (p) { return p; }; + P.compPointToSource = function (p) { return p; }; + P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; + P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; +})(); + +function TextLayer(comp, id) { return this.__init(comp, id, "TextLayer"); } +TextLayer.prototype = Object.create(AVLayer.prototype); +TextLayer.prototype.constructor = TextLayer; +Object.defineProperty(TextLayer.prototype, "source", { get: function () { return null; } }); + +function ShapeLayer(comp, id) { return this.__init(comp, id, "ShapeLayer"); } +ShapeLayer.prototype = Object.create(AVLayer.prototype); +ShapeLayer.prototype.constructor = ShapeLayer; +Object.defineProperty(ShapeLayer.prototype, "source", { get: function () { return null; } }); + +function CameraLayer(comp, id) { return this.__init(comp, id, "CameraLayer"); } +CameraLayer.prototype = Object.create(Layer.prototype); +CameraLayer.prototype.constructor = CameraLayer; + +function LightLayer(comp, id) { return this.__init(comp, id, "LightLayer"); } +LightLayer.prototype = Object.create(Layer.prototype); +LightLayer.prototype.constructor = LightLayer; +Object.defineProperty(LightLayer.prototype, "lightType", { + get: function () { return LightType[this.__info().lightType.toUpperCase()]; }, + set: function (v) { + var k = __enumKey(LightType, v); + if (!k) throw __err("Bad LightType value " + v); + __call("layer.lightSettings", this.__p1({ kind: k.charAt(0) + k.substring(1).toLowerCase() })); + }, +}); + +// ------------------------------------------------------------------ collections + +function LayerCollection(comp) { + this.__comp = comp; + return new Proxy(this, __coll); +} +LayerCollection.prototype = { + constructor: LayerCollection, + __ids: function () { return __get("item", { id: this.__comp }).layers; }, + get length() { return this.__ids().length; }, + __at: function (i) { var ids = this.__ids(); return i >= 1 && i <= ids.length ? __layer(this.__comp, ids[i - 1]) : undefined; }, + __new: function (r) { return __layer(this.__comp, r.layer); }, + __dur: function (layer, duration) { + if (duration !== undefined && duration !== null) { + var i = layer.__info(); + __call("layer.timing", { comp: this.__comp, layers: [layer.__id], out: i.inPoint + __num(duration, "duration") }); + } + return layer; + }, + add: function (item, duration) { + if (!item || item.id === undefined) throw __err("add(): expected an item"); + var l = this.__new(__call("layer.addItem", { comp: this.__comp, item: item.id, time: 0 })); + return this.__dur(l, duration); + }, + addSolid: function (color, name, width, height, pixelAspect, duration) { + var p = { comp: this.__comp, color: [color[0], color[1], color[2]] }; + if (name !== undefined) p.name = String(name); + if (width !== undefined) p.width = Math.round(__num(width, "width")); + if (height !== undefined) p.height = Math.round(__num(height, "height")); + return this.__dur(this.__new(__call("layer.newSolid", p)), duration); + }, + addNull: function (duration) { return this.__dur(this.__new(__call("layer.newNull", { comp: this.__comp })), duration); }, + addText: function (text) { + var t = text instanceof TextDocument ? text : new TextDocument(text === undefined ? "" : String(text)); + var p = t.__attrs(); + p.comp = this.__comp; + if (p.text === undefined) p.text = t.text; + return this.__new(__call("layer.newText", p)); + }, + addBoxText: function (size, text) { + var c = __get("item", { id: this.__comp }); + var w = size[0], h = size[1]; + var t = text instanceof TextDocument ? text : new TextDocument(text === undefined ? "" : String(text)); + var p = t.__attrs(); + p.comp = this.__comp; + if (p.text === undefined) p.text = t.text; + p.box = [(c.width - w) / 2, (c.height - h) / 2, w, h]; + return this.__new(__call("layer.newText", p)); + }, + addShape: function () { return this.__new(__call("layer.newShape", { comp: this.__comp, kind: "none" })); }, + addCamera: function (name, center) { + var p = { comp: this.__comp, name: String(name) }; + if (center) p.poi = [center[0], center[1], 0]; + return this.__new(__call("layer.newCamera", p)); + }, + addLight: function (name, center) { + var p = { comp: this.__comp, name: String(name), kind: "Point" }; + if (center) p.poi = [center[0], center[1], 0]; + return this.__new(__call("layer.newLight", p)); + }, + precompose: function (indices, name, moveAllAttributes) { + var ids = this.__ids(), sel = []; + for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]); + var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" }); + return new CompItem(r.comp); + }, + byName: function (name) { + var ids = this.__ids(); + for (var i = 0; i < ids.length; i++) { + var l = __get("layer", { comp: this.__comp, layer: ids[i] }); + if (l.name === name) return __layer(this.__comp, ids[i]); + } + return null; + }, + toString: function () { return "[object LayerCollection]"; }, +}; + +function ItemCollection(folder) { + this.__folder = folder; + return new Proxy(this, __coll); +} +ItemCollection.prototype = { + constructor: ItemCollection, + __ids: function () { + if (this.__folder === 0) return __get("project").items; + return __get("item", { id: this.__folder }).children; + }, + get length() { return this.__ids().length; }, + __at: function (i) { var ids = this.__ids(); return i >= 1 && i <= ids.length ? __item(ids[i - 1]) : undefined; }, + __place: function (id) { + if (this.__folder) __call("project.move", { items: [id], folder: this.__folder }); + return __item(id); + }, + addComp: function (name, width, height, pixelAspect, duration, frameRate) { + var r = __call("comp.new", { + name: String(name), width: Math.round(__num(width, "width")), height: Math.round(__num(height, "height")), + pixelAspect: __num(pixelAspect, "pixelAspect"), duration: __num(duration, "duration"), frameRate: __num(frameRate, "frameRate"), open: false, + }); + return this.__place(r.comp); + }, + addFolder: function (name) { + var r = __call("project.newFolder", { name: String(name) }); + var id = r && typeof r === "object" ? (r.folder || r.item || r.id) : r; + return this.__place(id); + }, + toString: function () { return "[object ItemCollection]"; }, +}; + +// ------------------------------------------------------------------ items + +function __item(id) { + if (id === null || id === undefined) return null; + if (id === 0) return new FolderItem(0); + var i = __get("item", { id: id }); + switch (i.kind) { + case "comp": return new CompItem(id); + case "folder": return new FolderItem(id); + default: return new FootageItem(id); + } +} + +function Item() {} +Item.prototype = { + __info: function () { return __get("item", { id: this.__id }); }, + toString: function () { return "[object " + this.__kind + "]"; }, + get id() { return this.__id; }, + get name() { return this.__info().name; }, + set name(v) { __call("project.rename", { item: this.__id, name: String(v) }); }, + get typeName() { return this.__info().typeName; }, + get comment() { return this.__info().comment; }, + set comment(v) { __call("project.setComment", { items: [this.__id], comment: String(v) }); }, + get label() { return this.__info().label; }, + set label(v) { __call("project.setLabel", { items: [this.__id], label: __num(v, "label") }); }, + get parentFolder() { return __item(this.__info().parent); }, + set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); }, + get selected() { return this.__info().selected; }, + set selected(v) { if (v) __call("project.select", { items: [this.__id], add: true }); }, + get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); }, + remove: function () { __call("project.delete", { items: [this.__id] }); }, +}; + +function AVItem() {} +AVItem.prototype = Object.create(Item.prototype); +(function () { + function def(name, get, set) { Object.defineProperty(AVItem.prototype, name, { get: get, set: set }); } + def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")) }); }); + def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")) }); }); + def("pixelAspect", function () { return this.__info().pixelAspect; }, function (v) { this.__settings({ pixelAspect: __num(v) }); }); + def("duration", function () { return this.__info().duration; }, function (v) { this.__settings({ duration: __num(v, "duration") }); }); + def("frameRate", function () { return this.__info().frameRate; }, function (v) { this.__settings({ frameRate: __num(v, "frameRate") }); }); + def("frameDuration", function () { var f = this.__info().frameRate; return f ? 1 / f : 0; }, function (v) { this.__settings({ frameRate: 1 / __num(v) }); }); + def("hasVideo", function () { return this.__info().hasVideo; }); + def("hasAudio", function () { return this.__info().hasAudio; }); + def("footageMissing", function () { return !!this.__info().footageMissing; }); + def("useProxy", function () { return false; }); + def("proxySource", function () { return null; }); + def("isMediaReplacementCompatible", function () { return false; }); + def("time", function () { return 0; }); +})(); +AVItem.prototype.__settings = function () { throw __err("can't change this item's settings from a script"); }; + +function CompItem(id) { + this.__id = id; + this.__kind = "CompItem"; +} +CompItem.prototype = Object.create(AVItem.prototype); +CompItem.prototype.constructor = CompItem; +(function () { + var P = CompItem.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + P.__settings = function (o) { o.comp = this.__id; __call("comp.settings", o); }; + def("typeName", function () { return "Composition"; }); + def("layers", function () { return new LayerCollection(this.__id); }); + def("numLayers", function () { return this.__info().layers.length; }); + def("selectedLayers", function () { var c = this.__id; return this.__info().selectedLayers.map(function (l) { return __layer(c, l); }); }); + def("selectedProperties", function () { + var c = this.__id, out = []; + this.__info().selectedProperties.forEach(function (lp) { + var L = __layer(c, lp[0]); + out.push(__propOf(L, __get("node", { comp: c, layer: lp[0], uid: lp[1] }))); + }); + return out; + }); + def("activeCamera", function () { + var ids = this.__info().layers; + for (var i = 0; i < ids.length; i++) { + var l = __get("layer", { comp: this.__id, layer: ids[i] }); + if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, ids[i]); + } + return null; + }); + def("time", function () { return this.__info().time; }, function (v) { __call("time.set", { comp: this.__id, time: __num(v, "time") }); }); + def("workAreaStart", function () { return this.__info().workAreaStart; }, function (v) { + var i = this.__info(); + __call("comp.workArea", { comp: this.__id, start: __num(v), end: __num(v) + i.workAreaDuration }); + }); + def("workAreaDuration", function () { return this.__info().workAreaDuration; }, function (v) { + var i = this.__info(); + __call("comp.workArea", { comp: this.__id, start: i.workAreaStart, end: i.workAreaStart + __num(v) }); + }); + def("bgColor", function () { return this.__info().bgColor; }, function (v) { this.__settings({ background: [v[0], v[1], v[2]] }); }); + def("displayStartTime", function () { return this.__info().displayStartTime; }, function (v) { this.__settings({ startTime: __num(v) }); }); + def("hideShyLayers", function () { return this.__info().hideShyLayers; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "hideShy", value: !!v }); }); + def("motionBlur", function () { return this.__info().motionBlur; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "motionBlur", value: !!v }); }); + def("frameBlending", function () { return this.__info().frameBlending; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "frameBlending", value: !!v }); }); + def("draft3d", function () { return this.__info().draft3d; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "draft3d", value: !!v }); }); + def("shutterAngle", function () { return this.__info().shutterAngle; }, function (v) { this.__settings({ shutterAngle: __num(v) }); }); + def("shutterPhase", function () { return this.__info().shutterPhase; }, function (v) { this.__settings({ shutterPhase: __num(v) }); }); + def("motionBlurSamplesPerFrame", function () { return this.__info().motionBlurSamplesPerFrame; }, function (v) { this.__settings({ motionBlurSamples: __num(v) }); }); + def("renderer", function () { return this.__info().renderer; }, function (v) { + this.__settings({ renderer: String(v).toLowerCase().indexOf("advanced") >= 0 ? "advanced3D" : "classic3D" }); + }); + def("renderers", function () { return ["ADBE Classic 3D", "ADBE Advanced 3d"]; }); + def("markerProperty", function () { return new MarkerProperty(this.__id, null); }); + def("hasVideo", function () { return true; }); + P.layer = function (a, b) { + var ids = this.__info().layers; + if (typeof a === "number") { + if (b !== undefined) { + // layer(otherLayer, relIndex) + throw __err("layer(index, relIndex) is not supported"); + } + if (a < 1 || a > ids.length || Math.floor(a) !== a) throw __err("layer index " + a + " is out of range 1.." + ids.length); + return __layer(this.__id, ids[a - 1]); + } + if (a && a.__id !== undefined && typeof b === "number") { + var j = a.index + b; + return j >= 1 && j <= ids.length ? __layer(this.__id, ids[j - 1]) : null; + } + return new LayerCollection(this.__id).byName(String(a)); + }; + P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; + P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; + P.exportAsMotionGraphicsTemplate = function () { return false; }; +})(); + +function FootageItem(id) { + this.__id = id; + this.__kind = "FootageItem"; +} +FootageItem.prototype = Object.create(AVItem.prototype); +FootageItem.prototype.constructor = FootageItem; +Object.defineProperty(FootageItem.prototype, "file", { + get: function () { var f = this.__info().file; return f ? new File(f) : null; }, +}); +Object.defineProperty(FootageItem.prototype, "mainSource", { + get: function () { + var i = this.__info(); + if (i.kind === "solid") return { isStill: true, color: i.color, hasAlpha: false, toString: function () { return "[object SolidSource]"; } }; + return { isStill: !!i.isStill, file: i.file ? new File(i.file) : null, missingFootagePath: i.footageMissing ? i.file : "", loop: i.loop, hasAlpha: false, toString: function () { return "[object FileSource]"; } }; + }, +}); +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +FootageItem.prototype.replaceWithSolid = function (color, name, w, h) { + __call("file.replaceWithSolid", { item: this.__id, color: [color[0], color[1], color[2]], width: w, height: h }); + if (name !== undefined) this.name = name; +}; +FootageItem.prototype.openInViewer = function () { return null; }; + +function FolderItem(id) { + this.__id = id; + this.__kind = "FolderItem"; +} +FolderItem.prototype = Object.create(Item.prototype); +FolderItem.prototype.constructor = FolderItem; +(function () { + var P = FolderItem.prototype; + P.__info = function () { + if (this.__id === 0) return { id: 0, name: "Root", typeName: "Folder", comment: "", label: 0, parent: null, selected: false, usedIn: [], children: [] }; + return __get("item", { id: this.__id }); + }; + Object.defineProperty(P, "items", { get: function () { return new ItemCollection(this.__id); } }); + Object.defineProperty(P, "numItems", { get: function () { return new ItemCollection(this.__id).length; } }); + Object.defineProperty(P, "parentFolder", { + get: function () { return this.__id === 0 ? null : __item(this.__info().parent); }, + set: function (f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); }, + }); + P.item = function (i) { + var v = new ItemCollection(this.__id).__at(__num(i, "index")); + if (v === undefined) throw __err("item index " + i + " is out of range"); + return v; + }; +})(); + +// ------------------------------------------------------------------ render queue + +function OutputModule(rq, n) { + this.__rq = rq; + this.__n = n; +} +OutputModule.prototype = { + constructor: OutputModule, + __info: function () { return this.__rq.__info(); }, + get name() { var i = this.__info(); return this.__n === 1 ? i.outputModuleSummary : i.extraOutputs[this.__n - 2].summary; }, + get file() { + var i = this.__info(); + var p = this.__n === 1 ? i.outputPath : i.extraOutputs[this.__n - 2].outputPath; + return p ? new File(p) : null; + }, + set file(f) { __call("renderQueue.setOutput", { item: this.__rq.__id, module: this.__n, path: __str(f) }); }, + get templates() { return ["H.264", "ProRes", "PNG Sequence", "JPEG Sequence", "TIFF Sequence", "OpenEXR Sequence", "GIF"]; }, + applyTemplate: function (name) { + var n = String(name).toLowerCase(); + var f = n.indexOf("prores") >= 0 ? "prores" : n.indexOf("png") >= 0 || n.indexOf("lossless") >= 0 ? "png" : n.indexOf("jpeg") >= 0 || n.indexOf("jpg") >= 0 ? "jpeg" + : n.indexOf("tiff") >= 0 ? "tiff" : n.indexOf("exr") >= 0 ? "exr" : n.indexOf("gif") >= 0 ? "gif" : n.indexOf("264") >= 0 ? "h264" : null; + if (!f) throw __err("Unknown output module template \"" + name + "\""); + __call("renderQueue.setOutputModule", { item: this.__rq.__id, module: this.__n, format: f }); + }, + remove: function () { throw __err("removing output modules is not supported"); }, + toString: function () { return "[object OutputModule]"; }, +}; + +var __rqStatus = { "Unqueued": 3014, "Queued": 3015, "Needs Output": 3013, "Rendering": 3016, "Done": 3019, "User Stopped": 3017, "Failed": 3018 }; + +function RenderQueueItem(id) { this.__id = id; } +RenderQueueItem.prototype = { + constructor: RenderQueueItem, + __info: function () { + var l = __call("renderQueue.list", {}).items; + for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i]; + throw __err("the render queue item no longer exists"); + }, + get comp() { return new CompItem(this.__info().comp); }, + get status() { + var s = this.__info().statusLabel; + for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k]; + return RQItemStatus.QUEUED; + }, + get render() { return this.__info().render; }, + set render(v) { __call("renderQueue.setRender", { item: this.__id, render: !!v }); }, + get numOutputModules() { return this.__info().outputModules; }, + get outputModules() { + var n = this.numOutputModules, out = []; + for (var i = 1; i <= n; i++) out.push(new OutputModule(this, i)); + return out; + }, + outputModule: function (i) { + if (i < 1 || i > this.numOutputModules) throw __err("output module index " + i + " is out of range"); + return new OutputModule(this, i); + }, + get timeSpanStart() { var s = this.__info().settings; return s.start !== undefined ? s.start : 0; }, + set timeSpanStart(v) { __call("renderQueue.setRenderSettings", { item: this.__id, timeSpan: "custom", start: __num(v), end: __num(v) + this.timeSpanDuration }); }, + get timeSpanDuration() { var s = this.__info().settings; return s.end !== undefined && s.start !== undefined ? s.end - s.start : this.comp.duration; }, + set timeSpanDuration(v) { var s = this.timeSpanStart; __call("renderQueue.setRenderSettings", { item: this.__id, timeSpan: "custom", start: s, end: s + __num(v) }); }, + get elapsedSeconds() { return this.__info().render_time || 0; }, + get startTime() { var s = this.__info().started; return s ? new Date(s * 1000) : null; }, + get logType() { return LogType.ERRORS_ONLY; }, + set logType(v) {}, + get skipFrames() { return 0; }, + outputModuleAdd: function () { __call("render.addOutputModule", { item: this.__id }); }, + remove: function () { __call("renderQueue.remove", { item: this.__id }); }, + duplicate: function () { + var before = __call("renderQueue.list", {}).items.map(function (i) { return i.id; }); + __call("renderQueue.duplicate", { item: this.__id }); + var after = __call("renderQueue.list", {}).items; + for (var i = 0; i < after.length; i++) if (before.indexOf(after[i].id) < 0) return new RenderQueueItem(after[i].id); + return null; + }, + applyTemplate: function () {}, + getSettings: function () { return this.__info().settings; }, + setSettings: function (o) { __call("renderQueue.setRenderSettings", Object.assign({ item: this.__id }, o)); }, + toString: function () { return "[object RenderQueueItem]"; }, +}; + +function RQItemCollection() { return new Proxy(this, __coll); } +RQItemCollection.prototype = { + constructor: RQItemCollection, + __list: function () { return __call("renderQueue.list", {}).items; }, + get length() { return this.__list().length; }, + __at: function (i) { var l = this.__list(); return i >= 1 && i <= l.length ? new RenderQueueItem(l[i - 1].id) : undefined; }, + add: function (comp) { + if (!(comp instanceof CompItem)) throw __err("add(): expected a CompItem"); + __call("renderQueue.add", { comp: comp.__id }); + var l = this.__list(); + return new RenderQueueItem(l[l.length - 1].id); + }, + toString: function () { return "[object RQItemCollection]"; }, +}; + +function RenderQueue() {} +RenderQueue.prototype = { + constructor: RenderQueue, + get items() { return new RQItemCollection(); }, + get numItems() { return new RQItemCollection().length; }, + get rendering() { return !!__call("renderQueue.list", {}).rendering; }, + get canQueueInAME() { return false; }, + get queueNotify() { return false; }, + set queueNotify(v) {}, + item: function (i) { + var v = new RQItemCollection().__at(__num(i, "index")); + if (v === undefined) throw __err("render queue index " + i + " is out of range"); + return v; + }, + render: function () { __call("renderQueue.render", { wait: true }); }, + stopRendering: function () { __call("renderQueue.stop", {}); }, + pauseRendering: function () {}, + showWindow: function () {}, + queueInAME: function () { throw __err("Adobe Media Encoder is not available: render with app.project.renderQueue.render()"); }, + toString: function () { return "[object RenderQueue]"; }, +}; + +// ------------------------------------------------------------------ project and app + +function ImportOptions(file) { + if (!(this instanceof ImportOptions)) return new ImportOptions(file); + this.file = file || null; + this.importAs = ImportAsType.FOOTAGE; + this.sequence = false; + this.forceAlphabetical = false; + this.rangeStart = 0; + this.rangeEnd = 0; +} +ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; }; + +function Project() {} +Project.prototype = { + constructor: Project, + __info: function () { return __get("project"); }, + get items() { return new ItemCollection(0); }, + get numItems() { return this.__info().items.length; }, + get rootFolder() { return new FolderItem(0); }, + get activeItem() { return __item(this.__info().active); }, + get selection() { return this.__info().selection.map(__item); }, + get file() { var p = this.__info().path; return p ? new File(p) : null; }, + get dirty() { return this.__info().dirty; }, + get renderQueue() { return new RenderQueue(); }, + get bitsPerChannel() { return this.__info().bitsPerChannel; }, + set bitsPerChannel(v) { __call("file.projectSettings", { bitDepth: __num(v) }); }, + get linearBlending() { return this.__info().linearBlending; }, + set linearBlending(v) { __call("file.projectSettings", { blendLinear: !!v }); }, + get linearizeWorkingSpace() { return this.__info().linearizeWorkingSpace; }, + set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, + get workingSpace() { return this.__info().workingSpace; }, + set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, + set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, + get expressionEngine() { return "javascript-1.0"; }, + set expressionEngine(v) {}, + item: function (i) { + var v = new ItemCollection(0).__at(__num(i, "index")); + if (v === undefined) throw __err("item index " + i + " is out of range 1.." + this.numItems); + return v; + }, + itemByID: function (id) { + try { return __item(__num(id)); } catch (e) { return null; } + }, + layerByID: function () { return null; }, + save: function (file) { + if (file) __call("file.saveAs", { path: __str(file) }); + else __call("file.save", {}); + return true; + }, + saveWithDialog: function () { if (this.file) return this.save(); return false; }, + close: function (opt) { + if (opt === CloseOptions.SAVE_CHANGES && this.file) this.save(); + __call("file.newProject", {}); + return true; + }, + importFile: function (opts) { + var f = opts instanceof ImportOptions ? opts.file : opts; + if (!f) throw __err("importFile(): no file"); + var r = __call("file.import", { paths: [__str(f)] }); + if (r.errors && r.errors.length) throw __err(r.errors.join("; ")); + return __item(r.items[0]); + }, + importFileWithDialog: function () { return null; }, + importPlaceholder: function (name, w, h, rate, dur) { + var r = __call("file.importPlaceholder", { name: String(name), width: w, height: h, frameRate: rate, duration: dur }); + var id = r && typeof r === "object" ? (r.item || r.id) : r; + return __item(id); + }, + consolidateFootage: function () { return __call("file.consolidateFootage", {}); }, + removeUnusedFootage: function () { return __call("file.removeUnusedFootage", {}); }, + reduceProject: function (items) { + __call("project.select", { items: items.map(function (i) { return i.__id; }) }); + return __call("file.reduceProject", {}); + }, + autoFixExpressions: function () {}, + showWindow: function () {}, + toString: function () { return "[object Project]"; }, +}; + +var __tasks = {}; +var app = { + get project() { return new Project(); }, + get version() { return __get("app").version; }, + get buildName() { return __get("app").buildName; }, + get buildNumber() { return 0; }, + get isWatchFolder() { return false; }, + get isRenderEngine() { return false; }, + get isoLanguage() { return "en_US"; }, + get language() { return 0; }, + get memoryInUse() { return 0; }, + get effects() { return __get("effects").map(function (e) { return { displayName: e.name, matchName: e.matchName, category: e.category, version: "1.0" }; }); }, + get activeViewer() { return null; }, + get availableGPUAccelTypes() { return []; }, + exitAfterLaunchAndEval: false, + exitCode: 0, + saveProjectOnCrash: true, + onError: null, + settings: { + __s: {}, + haveSetting: function (sec, key) { return (sec + "/" + key) in this.__s; }, + getSetting: function (sec, key) { return this.__s[sec + "/" + key] || ""; }, + saveSetting: function (sec, key, v) { this.__s[sec + "/" + key] = String(v); }, + }, + preferences: { + havePref: function () { return false; }, + getPrefAsString: function () { return ""; }, + getPrefAsLong: function () { return 0; }, + getPrefAsBool: function () { return false; }, + getPrefAsFloat: function () { return 0; }, + savePrefAsString: function () {}, + savePrefAsLong: function () {}, + savePrefAsBool: function () {}, + savePrefAsFloat: function () {}, + reload: function () {}, + }, + newProject: function () { __call("file.newProject", {}); return new Project(); }, + open: function (file) { + if (!file) return null; + __call("file.open", { path: __str(file) }); + return new Project(); + }, + quit: function () {}, + beginUndoGroup: function (name) { __undo("begin", name === undefined ? "Script" : String(name)); }, + endUndoGroup: function () { __undo("end", ""); }, + beginSuppressDialogs: function () {}, + endSuppressDialogs: function () {}, + purge: function () { __call("edit.purge", { what: "all" }); }, + findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); }, + executeCommand: function (id) { + var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id); + if (!cmd) throw __err("Unknown command id " + id); + return __call(cmd, {}); + }, + scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), !!repeat); }, + cancelTask: function (id) { __task("cancel", id); }, + setMemoryUsageLimits: function () {}, + setMultiFrameRenderingConfig: function () {}, + setSavePreferencesOnQuit: function () {}, + activate: function () {}, + watchFolder: function () { throw __err("watch folders are not supported"); }, + pauseWatchFolder: function () {}, + endWatchFolder: function () {}, + parseSwatchFile: function () { throw __err("parseSwatchFile is not supported"); }, + // EffectCraft: run any engine command by id with parameters (see `command.list`). + run: function (id, params) { return __call(String(id), params || {}); }, + toString: function () { return "[object Application]"; }, +}; + +// The completion value of a script, as JSON text for the host. +function __result(v) { + if (v === undefined) return undefined; + if (v === null || typeof v === "number" || typeof v === "boolean" || typeof v === "string") return JSON.stringify(v); + if (v instanceof Layer || v instanceof Item || v instanceof PropertyBase || v instanceof Project || v instanceof TextDocument) { + var d = { type: v.toString() }; + try { d.name = v.name; } catch (e) {} + if (v instanceof TextDocument) d.text = v.text; + return JSON.stringify(d); + } + try { + var s = JSON.stringify(v); + return s === undefined ? JSON.stringify(String(v)) : s; + } catch (e) { + return JSON.stringify(String(v)); + } +} diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs new file mode 100644 index 00000000..61b49595 --- /dev/null +++ b/crates/script/src/runtime.rs @@ -0,0 +1,546 @@ +//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`, +//! `__file`), the session hand-off while a script runs, undo groups and error reporting. + +use std::cell::RefCell; +use std::mem::ManuallyDrop; +use std::sync::Arc; + +use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use effectcraft_engine::project::Project; +use effectcraft_engine::{ScriptRequest, Session}; +use serde_json::{Value as J, json}; + +const PRELUDE: &str = include_str!("prelude.js"); + +/// Engine commands that create layers: like After Effects, scripts add new layers at the top of +/// the stack (the UI inserts above the selection). +const CREATES_LAYER: &[&str] = + &["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"]; + +/// What a script run produced. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Outcome { + /// The value of the script's last expression statement (JSON when it converts; model objects + /// as strings like `"[object CompItem]"`). + pub result: J, + /// `writeLn` / `$.writeln` / `alert` output, one entry per line. + pub output: Vec, + pub error: Option, +} + +/// An uncaught script error. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScriptError { + pub message: String, + pub line: Option, + pub column: Option, + pub file: String, +} + +impl Outcome { + pub fn ok(&self) -> bool { + self.error.is_none() + } + pub fn to_json(&self) -> J { + json!({ + "ok": self.ok(), + "result": self.result, + "output": self.output.join("\n"), + "error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})), + }) + } +} + +/// The session and per-run state while a script runs (the natives reach it here; nothing +/// borrowed ever enters the JS heap). +struct Active { + session: Session, + output: Vec, + /// Open undo groups: (name, project snapshot when the outermost group began). + groups: Vec<(String, Arc)>, + /// `app.scheduleTask` queue: (id, code, repeat). + tasks: Vec<(u32, String, bool)>, + next_task: u32, + name: String, +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; + /// The Script Console's persistent context (never dropped, like the expression runtime: boa's + /// own thread-local heap may be torn down first at thread exit). + static CONSOLE: RefCell>> = const { RefCell::new(None) }; +} + +fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result { + ACTIVE.with(|a| match a.try_borrow_mut() { + Ok(mut g) => match g.as_mut() { + Some(act) => f(act), + None => Err("no script is running".into()), + }, + Err(_) => Err("scripts can't run scripts".into()), + }) +} + +fn throw(msg: impl Into) -> boa_engine::JsError { + JsNativeError::error().with_message(msg.into()).into() +} + +fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped()) +} + +fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + let s = arg_string(args, i, ctx)?; + if s.is_empty() || s == "undefined" { + return Ok(json!({})); + } + serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}"))) +} + +fn js_str(s: &str) -> JsValue { + JsValue::from(JsString::from(s)) +} + +/// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the +/// target comp made active for its duration. +fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let id = arg_string(args, 0, ctx)?; + let params = arg_json(args, 1, ctx)?; + if id == "script.run" || id == "file.runScript" { + return Err(throw("scripts can't run scripts")); + } + let r = with_active(|a| Ok(exec(&mut a.session, &id, params))); + match r { + Ok(Ok(v)) => Ok(js_str(&v.to_string())), + Ok(Err(e)) | Err(e) => Err(throw(e)), + } +} + +/// Run a command for a script: the `comp` it targets is active while it runs (commands act on +/// the active comp), then the user's active comp comes back. +pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { + use effectcraft_engine::project::ItemId; + let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some()); + let prev = s.state.active_comp; + if let Some(c) = target + && prev != Some(c) + { + s.state.active_comp = Some(c); + } + if CREATES_LAYER.contains(&id) { + s.state.selected_layers.clear(); + } + let r = s.execute(id, params); + if let Some(c) = target + && prev != Some(c) + && s.state.active_comp == Some(c) + && id != "comp.open" + { + match prev { + Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p), + _ => { + if !s.state.open_comps.contains(&c) { + s.state.open_comps.push(c); + } + } + } + } + r.map_err(|e| e.to_string()) +} + +/// `__query(kind, json)` → JSON. +fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let kind = arg_string(args, 0, ctx)?; + let a = arg_json(args, 1, ctx)?; + let r = with_active(|act| match kind.as_str() { + "app" => Ok(json!({ + "version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")), + "buildName": env!("CARGO_PKG_VERSION"), + "scriptName": act.name, + "allowFiles": act.session.prefs.scripting.allow_scripts_write_files, + "projectDir": project_dir(&act.session), + })), + _ => crate::model::query(&mut act.session, &kind, &a), + }); + match r { + Ok(v) => Ok(js_str(&v.to_string())), + Err(e) => Err(throw(e)), + } +} + +/// `__print(text)`: `writeLn`, `$.writeln`, `alert`. +fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let text = arg_string(args, 0, ctx)?; + let _ = with_active(|a| { + a.output.extend(text.split('\n').map(str::to_string)); + Ok(()) + }); + Ok(JsValue::undefined()) +} + +/// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every +/// command run inside the outermost group folds into one undo step named after the group. +fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let name = arg_string(args, 1, ctx)?; + with_active(|a| { + match op.as_str() { + "begin" => { + let snap = a.session.project.clone(); + a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap)); + } + _ => { + if a.groups.len() == 1 { + let (name, snap) = a.groups.pop().unwrap_or_default(); + collapse(&mut a.session, &name, &snap); + } else { + a.groups.pop(); + } + } + } + Ok(()) + }) + .map_err(throw)?; + Ok(JsValue::undefined()) +} + +/// Fold the undo steps recorded since the project was `snap` into one step called `name`. +fn collapse(s: &mut Session, name: &str, snap: &Arc) { + let h = &mut s.history; + let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { + // Nothing was recorded (or history was reset by New / Open Project). + return; + }; + let first = h.undo[start].1.clone(); + h.undo.truncate(start); + h.undo.push((name.to_string(), first)); + h.merge_key = None; +} + +/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`. +fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + if op == "cancel" { + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; + let _ = with_active(|a| { + a.tasks.retain(|t| t.0 != id); + Ok(()) + }); + return Ok(JsValue::undefined()); + } + let code = arg_string(args, 1, ctx)?; + let repeat = args.get(2).cloned().unwrap_or_default().to_boolean(); + let id = with_active(|a| { + a.next_task += 1; + let id = a.next_task; + a.tasks.push((id, code, repeat)); + Ok(id) + }) + .map_err(throw)?; + Ok(JsValue::from(id)) +} + +fn project_dir(s: &Session) -> Option { + let p = s.path.as_deref()?; + std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string()) +} + +/// Lexically normalized absolute path (`a/./b/../c` → `a/c`). +fn normalize(p: &str) -> std::path::PathBuf { + use std::path::{Component, Path, PathBuf}; + let path = Path::new(p); + let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) }; + let mut out = PathBuf::new(); + for c in abs.components() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + c => out.push(c), + } + } + // Resolve symlinks where the file (or its folder) exists, so links can't escape the project. + std::fs::canonicalize(&out) + .or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default()))) + .unwrap_or(out) +} + +const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; + +/// The file-access gate: reads under the project's folder are always allowed; other reads, all +/// writes and listing other folders need the Allow Scripts to Write Files preference. +fn check_access(s: &Session, path: &str, write: bool) -> Result { + let p = normalize(path); + if s.prefs.scripting.allow_scripts_write_files { + return Ok(p); + } + if write { + return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on")); + } + if let Some(dir) = project_dir(s) { + let d = normalize(&dir); + if p.starts_with(&d) { + return Ok(p); + } + } + Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on")) +} + +/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` → +/// names, `mkdir`, `remove`, `isDir`. +fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let path = arg_string(args, 1, ctx)?; + let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() }; + let r: Result = with_active(|a| { + let s = &a.session; + match op.as_str() { + "access" => Ok(json!(check_access(s, &path, false)?.exists())), + "exists" | "isDir" => { + // Unreadable locations report "missing" instead of failing. + let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) }; + Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() })) + } + "read" => { + let p = check_access(s, &path, false)?; + let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; + Ok(json!(String::from_utf8_lossy(&bytes))) + } + "write" | "append" => { + let p = check_access(s, &path, true)?; + let ps = p.to_string_lossy().to_string(); + let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] }; + bytes.extend_from_slice(data.as_bytes()); + s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?; + Ok(json!(bytes.len())) + } + "list" => { + let p = check_access(s, &path, false)?; + let mut names: Vec = std::fs::read_dir(&p) + .map_err(|e| format!("Unable to list {path}: {e}"))? + .filter_map(|e| e.ok()) + .map(|e| e.path().to_string_lossy().to_string()) + .collect(); + names.sort(); + Ok(json!(names)) + } + "mkdir" => { + let p = check_access(s, &path, true)?; + std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?; + Ok(json!(true)) + } + "remove" => { + let p = check_access(s, &path, true)?; + let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) }; + Ok(json!(r.is_ok())) + } + "network" => Err(if s.prefs.scripting.allow_scripts_write_files { + "network access from scripts is not supported in EffectCraft".to_string() + } else { + format!("scripts can't access the network unless {GATE} is on") + }), + _ => Err(format!("unknown file operation `{op}`")), + } + }); + match r { + Ok(v) => Ok(js_str(&v.to_string())), + Err(e) => Err(throw(e)), + } +} + +fn new_context() -> Result { + let mut ctx = Context::default(); + let limits = ctx.runtime_limits_mut(); + limits.set_loop_iteration_limit(200_000_000); + limits.set_recursion_limit(2000); + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ + (js_string!("__exec"), 2, native_exec), + (js_string!("__query"), 2, native_query), + (js_string!("__print"), 1, native_print), + (js_string!("__undo"), 2, native_undo), + (js_string!("__task"), 3, native_task), + (js_string!("__file"), 3, native_file), + ]; + for (name, len, f) in natives { + ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; + } + ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?; + Ok(ctx) +} + +/// Turn a boa error into a message with line and column (`code` is the user's script). +fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError { + // Thrown values: `throw new Error("x")`, `throw "x"`, native errors. + let display = e.to_string(); + let message = match e.as_opaque() { + Some(v) => { + let o = v.as_object(); + let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined()); + let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined()); + match (name, msg) { + (Some(n), Some(m)) => { + let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); + let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); + if n == "Error" { m } else { format!("{n}: {m}") } + } + _ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()), + } + } + None => { + let first = display.lines().next().unwrap_or("").trim(); + let first = first.split(" at line ").next().unwrap_or(first).trim(); + let first = match first.rfind(" (") { + Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(), + _ => first, + }; + first.strip_prefix("Error: ").unwrap_or(first).to_string() + } + }; + let (line, column) = position(&display, file, code); + ScriptError { message, line, column, file: file.to_string() } +} + +/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace +/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of +/// the undefined name in `code`. +fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { + if let Some(i) = msg.find("at line ") { + let rest = &msg[i + 8..]; + let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok()); + let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()); + return (line, col); + } + // `(file:12:5)`: the digits after the file name. + let pick = |l: &str| -> Option<(u32, u32)> { + let i = l.find(&format!("({file}:"))? + file.len() + 2; + let mut it = l[i..].split([':', ')']); + Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?)) + }; + let frames: Vec<&str> = msg.lines().skip(1).collect(); + if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) { + return (Some(a), Some(b)); + } + // `ReferenceError: name is not defined` without a position. + let first = msg.lines().next().unwrap_or(""); + if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) { + for (n, line) in code.lines().enumerate() { + let b = line.as_bytes(); + let mut from = 0; + while let Some(i) = line[from..].find(name).map(|i| i + from) { + let before = i.checked_sub(1).map(|j| b[j]); + let after = b.get(i + name.len()).copied(); + let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; + if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) { + return (Some(n as u32 + 1), Some(i as u32 + 1)); + } + from = i + name.len(); + } + } + } + (None, None) +} + +/// Evaluate `code` in `ctx` and convert the result. +fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result { + let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file)); + let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?; + let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?; + Ok(result_json(&v, ctx)) +} + +/// The script's completion value as JSON (`__result` in the prelude does the conversion). +fn result_json(v: &JsValue, ctx: &mut Context) -> J { + if v.is_undefined() { + return J::Null; + } + let global = ctx.global_object(); + let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null }; + match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) { + Ok(s) if s.is_string() => { + let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); + serde_json::from_str(&s).unwrap_or(J::String(s)) + } + _ => J::Null, + } +} + +/// Restores the caller's session when a run ends (on every exit path). +struct Restore<'a> { + target: &'a mut Session, +} + +impl Drop for Restore<'_> { + fn drop(&mut self) { + if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) { + *self.target = a.session; + } + } +} + +/// Run a script against `session`: the object model sees and edits that session through engine +/// commands. Script errors come back in [`Outcome::error`]. +pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { + let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)); + if running { + return Outcome { + error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }), + ..Default::default() + }; + } + let s = std::mem::take(session); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() })); + let guard = Restore { target: session }; + let file = if req.name.is_empty() { "script" } else { req.name }; + let mut result = if req.console { + CONSOLE.with(|c| { + let mut c = c.borrow_mut(); + if c.is_none() { + match new_context() { + Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)), + Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }), + } + } + let ctx = c.as_mut().map(|m| &mut **m).expect("console context"); + eval_in(ctx, req.code, file) + }) + } else { + match new_context() { + Ok(mut ctx) => { + let r = eval_in(&mut ctx, req.code, file); + // app.scheduleTask: run the queued one-shot tasks after the script, in order. + let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); + let mut r = r; + for (_, code, repeat) in tasks { + if repeat { + continue; + } + if let Err(e) = eval_in(&mut ctx, &code, "scheduled task") + && r.is_ok() + { + r = Err(e); + } + } + r + } + Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }), + } + }; + // Close undo groups left open; drop selections that no longer exist. + let output = with_active(|a| { + if let Some((name, snap)) = a.groups.first().cloned() { + collapse(&mut a.session, &name, &snap); + a.groups.clear(); + } + a.session.sanitize_state(); + Ok(std::mem::take(&mut a.output)) + }) + .unwrap_or_default(); + drop(guard); + let mut out = Outcome { output, ..Default::default() }; + match result.as_mut() { + Ok(v) => out.result = std::mem::take(v), + Err(e) => out.error = Some(e.clone()), + } + out +} diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs new file mode 100644 index 00000000..f4a4fcb6 --- /dev/null +++ b/crates/script/src/tests.rs @@ -0,0 +1,392 @@ +//! End-to-end scripts against a headless session: build with the object model, read back through +//! the engine. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{Comp, ItemId, Layer}; +use serde_json::{Value, json}; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() }; + crate::install(&mut s); + s +} + +fn ok(s: &mut Session, code: &str) -> Outcome { + let o = run_code(s, code, "test.jsx"); + assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output); + o +} + +fn comp_named<'a>(s: &'a Session, name: &str) -> (ItemId, &'a Comp) { + let it = s.project.items.values().find(|i| i.name == name && i.as_comp().is_some()).unwrap_or_else(|| panic!("no comp {name}")); + (it.id, it.as_comp().unwrap()) +} + +fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer { + c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")) +} + +#[test] +fn builds_a_comp_end_to_end() { + let mut s = session(); + let o = ok( + &mut s, + r#" + app.beginUndoGroup("Build"); + var comp = app.project.items.addComp("Main", 640, 360, 1, 4, 25); + var bg = comp.layers.addSolid([0.2, 0.4, 0.6], "BG", 640, 360, 1); + var txt = comp.layers.addText("Hello"); + txt.name = "Title"; + var pos = txt.property("ADBE Transform Group").property("ADBE Position"); + pos.setValueAtTime(0, [100, 180]); + pos.setValueAtTime(2, [540, 180]); + txt.transform.opacity.expression = "50 + 25"; + var blur = bg.Effects.addProperty("ADBE Gaussian Blur 2"); + blur.property("Blurriness").setValue(12); + var nul = comp.layers.addNull(); + txt.setParentWithJump(nul); + bg.blendingMode = BlendingMode.MULTIPLY; + var rq = app.project.renderQueue.items.add(comp); + rq.outputModule(1).file = new File("/tmp/effectcraft-script-test/out.mp4"); + app.endUndoGroup(); + writeLn("layers: " + comp.numLayers); + [comp.numLayers, pos.numKeys, pos.keyValue(2)[0], txt.transform.opacity.value, comp.layer(1).name] + "#, + ); + assert_eq!(o.output, vec!["layers: 3"]); + assert_eq!(o.result, json!([3, 2, 540, 75, "Null 1"])); + let (cid, c) = comp_named(&s, "Main"); + assert_eq!((c.width, c.height), (640, 360)); + assert_eq!(c.duration.seconds(), 4.0); + // AE adds new layers at the top. + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Null 1", "Title", "BG"]); + let title = layer(c, "Title"); + let p = title.props.prop("transform/position").unwrap(); + assert_eq!(p.keys.len(), 2); + assert_eq!(p.keys[1].value, KV::Vec3([540.0, 180.0, 0.0])); + assert_eq!(title.props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text, "50 + 25"); + assert_eq!(title.parent, Some(layer(c, "Null 1").id)); + let bg = layer(c, "BG"); + assert_eq!(bg.blend_mode, effectcraft_engine::color::BlendMode::Multiply); + let fx = bg.props.group("effects/#1").unwrap(); + assert_eq!(fx.match_id, "ec.blur.gaussian"); + assert_eq!(bg.props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.0)); + assert_eq!(s.project.render_queue.len(), 1); + assert_eq!(s.project.render_queue[0].comp, cid); + // One undo step for the whole group. + assert_eq!(s.history.undo.len(), 1); + assert_eq!(s.history.undo[0].0, "Build"); + assert!(s.undo()); + assert!(s.project.items.is_empty()); +} + +#[test] +fn undo_steps_without_a_group() { + let mut s = session(); + ok(&mut s, r#"var c = app.project.items.addComp("A", 100, 100, 1, 1, 24); c.layers.addNull(); c.layers.addNull();"#); + assert_eq!(s.history.undo.len(), 3); + // An unbalanced group is closed when the script ends. + ok(&mut s, r#"app.beginUndoGroup("Two"); var c = app.project.item(1); c.layers.addNull(); c.layers.addNull();"#); + assert_eq!(s.history.undo.len(), 4); + assert_eq!(s.history.undo[3].0, "Two"); + // Nested groups fold into the outermost one. + ok( + &mut s, + r#"app.beginUndoGroup("Outer"); app.beginUndoGroup("Inner"); app.project.item(1).layers.addNull(); app.endUndoGroup(); app.project.item(1).layers.addNull(); app.endUndoGroup();"#, + ); + assert_eq!(s.history.undo.len(), 5); + assert_eq!(s.history.undo[4].0, "Outer"); + s.undo(); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 4); +} + +#[test] +fn errors_report_line_and_column() { + let mut s = session(); + let o = run_code(&mut s, "var a = 1;\nvar b = 2;\nfoo.bar();\n", "broken.jsx"); + let e = o.error.expect("error"); + assert_eq!(e.line, Some(3), "{e:?}"); + assert!(e.message.contains("foo"), "{e:?}"); + assert_eq!(e.file, "broken.jsx"); + let o = run_code(&mut s, "var a = 1;\n\nvar = ;\n", "syntax.jsx"); + let e = o.error.expect("syntax error"); + assert_eq!(e.line, Some(3), "{e:?}"); + let o = run_code(&mut s, "writeLn('before');\nthrow new Error('custom failure');\n", "throw.jsx"); + let e = o.error.expect("thrown"); + assert_eq!(e.message, "custom failure"); + assert_eq!(e.line, Some(2), "{e:?}"); + assert_eq!(o.output, vec!["before"]); + // Errors from the object model point at the user's line. + let o = run_code(&mut s, "var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n\nc.layer(5);\n", "range.jsx"); + let e = o.error.expect("range error"); + assert!(e.message.contains("out of range"), "{e:?}"); + assert_eq!(e.line, Some(3), "{e:?}"); +} + +#[test] +fn match_names_and_lookups() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("M", 200, 200, 1, 2, 30); + var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1); + var tr = l.property("ADBE Transform Group"); + var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur"); + var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom"); + var sh = c.layers.addShape(); + var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group"); + var rect = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Rect"); + var fill = grp.property("Contents").addProperty("ADBE Vector Graphic - Fill"); + fill.property("ADBE Vector Fill Color").setValue([1, 0, 0]); + rect.property("ADBE Vector Rect Size").setValue([50, 60]); + [ + l.matchName, tr.matchName, tr.property("ADBE Position").matchName, tr.property("Anchor Point").matchName, + tr.property(1).name, tr.anchorPoint.matchName, l.transform.position.propertyValueType == PropertyValueType.ThreeD_SPATIAL, + fx.matchName, fx.property(1).matchName, fx.property("ADBE Gaussian Blur 2-0001").name, m.matchName, + m.property("ADBE Mask Shape").name, sh.matchName, grp.matchName, rect.matchName, fill.matchName, + tr.propertyType == PropertyType.NAMED_GROUP, l.Effects.propertyType == PropertyType.INDEXED_GROUP, + tr.position.parentProperty.matchName, tr.position.propertyDepth, l.property("Nope") === null, + fx.isEffect, m.isMask, l.Effects.numProperties + ] + "#, + ); + assert_eq!( + o.result, + json!([ + "ADBE AV Layer", + "ADBE Transform Group", + "ADBE Position", + "ADBE Anchor Point", + "Anchor Point", + "ADBE Anchor Point", + true, + "ADBE Gaussian Blur 2", + "ADBE Gaussian Blur 2-0001", + "Blurriness", + "ADBE Mask Atom", + "Mask Path", + "ADBE Vector Layer", + "ADBE Vector Group", + "ADBE Vector Shape - Rect", + "ADBE Vector Graphic - Fill", + true, + true, + "ADBE Transform Group", + 2, + true, + true, + true, + 1 + ]) + ); + let (_, c) = comp_named(&s, "M"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/#1/contents/rect/size").unwrap().value, KV::Vec2([50.0, 60.0])); + assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn keyframes_eases_and_interpolation() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("K", 100, 100, 1, 5, 25); + var l = c.layers.addNull(); + var r = l.transform.rotation; + r.setValuesAtTimes([0, 1, 2], [0, 90, 180]); + r.setInterpolationTypeAtKey(2, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.LINEAR); + r.setTemporalEaseAtKey(3, [new KeyframeEase(0, 75)]); + var idx = r.addKey(1.5); + r.removeKey(1); + var out = [r.numKeys, idx, r.keyTime(1), r.keyInInterpolationType(1) == KeyframeInterpolationType.HOLD, + r.keyOutInterpolationType(1) == KeyframeInterpolationType.LINEAR, r.keyInTemporalEase(3)[0].influence, + r.valueAtTime(1, false), r.isTimeVarying, r.nearestKeyIndex(1.9)]; + l.startTime = 1; + out.push(r.keyTime(1)); + out + "#, + ); + let v = o.result.as_array().unwrap(); + assert_eq!(v[0], json!(3)); + assert_eq!(v[1], json!(3)); + assert_eq!(v[2], json!(1)); + assert_eq!(v[3], json!(true)); + assert_eq!(v[4], json!(true)); + assert!((v[5].as_f64().unwrap() - 75.0).abs() < 1e-6, "{v:?}"); + assert_eq!(v[6], json!(90)); + assert_eq!(v[7], json!(true)); + assert_eq!(v[8], json!(3)); + // Moving the layer moves its keys (layer time): key 1 now sits at 2 s comp time. + assert_eq!(v[9], json!(2)); +} + +#[test] +fn text_documents() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("T", 400, 200, 1, 2, 30); + var t = c.layers.addText("abc"); + var st = t.property("ADBE Text Properties").property("ADBE Text Document"); + var doc = st.value; + doc.text = "Changed"; + doc.fontSize = 40; + doc.fillColor = [1, 0, 0]; + doc.justification = ParagraphJustification.CENTER_JUSTIFY; + doc.applyStroke = true; + doc.strokeWidth = 3; + st.setValue(doc); + var d2 = t.text.sourceText.value; + [d2.text, d2.fontSize, d2.fillColor, d2.justification == ParagraphJustification.CENTER_JUSTIFY, d2.strokeWidth, t instanceof TextLayer, t.name] + "#, + ); + assert_eq!(o.result, json!(["Changed", 40, [1, 0, 0], true, 3, true, "abc"])); + let (_, c) = comp_named(&s, "T"); + let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!() }; + assert_eq!(d.text, "Changed"); + assert_eq!(d.style_at(0).size, 40.0); +} + +#[test] +fn layers_items_and_markers() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var f = app.project.items.addFolder("Comps"); + var c = f.items.addComp("Inner", 100, 100, 1, 3, 30); + var main = app.project.items.addComp("Outer", 200, 200, 1, 6, 30); + var nested = main.layers.add(c); + var a = main.layers.addSolid([1, 0, 0], "A", 50, 50, 1, 2); + var b = main.layers.addSolid([0, 1, 0], "B", 50, 50, 1); + b.moveAfter(nested); + a.enabled = false; a.solo = true; a.locked = true; a.shy = true; + a.threeDLayer = true; + var cam = main.layers.addCamera("Cam", [100, 100]); + var lt = main.layers.addLight("Key", [100, 100]); + lt.lightType = LightType.SPOT; + var d = a.duplicate(); + d.name = "A copy"; + var mv = new MarkerValue("hello"); + mv.duration = 0.5; + main.markerProperty.setValueAtTime(1, mv); + b.marker.setValueAtTime(0.5, new MarkerValue("layer marker")); + b.inPoint = 1; b.outPoint = 3; + var pc = main.layers.precompose([b.index], "Pre", true); + [c.parentFolder.name, nested.source.name, a.outPoint, a.enabled, a.solo, a.locked, a.shy, a.threeDLayer, + cam instanceof CameraLayer, lt.lightType == LightType.SPOT, main.markerProperty.numKeys, main.markerProperty.keyValue(1).comment, + main.markerProperty.keyValue(1).duration, pc.name, pc instanceof CompItem, main.layer("A copy").index, app.project.numItems, + main.layers.byName("Pre") !== null, f.numItems] + "#, + ); + assert_eq!(o.result, json!(["Comps", "Inner", 2, false, true, true, true, true, true, true, 1, "hello", 0.5, "Pre", true, 3, 7, true, 1])); + let (_, c) = comp_named(&s, "Outer"); + assert_eq!(c.markers.len(), 1); + assert!( + c.layers + .iter() + .any(|l| l.name == "Key" + && matches!(l.source, effectcraft_engine::project::LayerSource::Light { kind: effectcraft_engine::project::LightKind::Spot })) + ); +} + +#[test] +fn security_gate_for_files() { + let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id())); + let proj = dir.join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write(proj.join("data.txt"), "inside").unwrap(); + std::fs::write(dir.join("secret.txt"), "outside").unwrap(); + let mut s = session(); + s.path = Some(proj.join("p.ecproj").to_string_lossy().to_string()); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + // Reading inside the project folder works. + let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); var t = f.read(); f.close(); t", q(&proj.join("data.txt")))); + assert_eq!(o.result, json!("inside")); + // Outside it (also via `..`) is refused. + for path in [dir.join("secret.txt"), proj.join("../secret.txt")] { + let o = run_code(&mut s, &format!("var f = new File({}); f.open('r');", q(&path)), "gate.jsx"); + let e = o.error.expect("blocked"); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + } + // Writing is refused anywhere, the network too. + let o = run_code(&mut s, &format!("var f = new File({}); f.open('w'); f.write('x'); f.close();", q(&proj.join("out.txt"))), "gate.jsx"); + assert!(o.error.is_some()); + assert!(!proj.join("out.txt").exists()); + assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some()); + assert!(run_code(&mut s, "system.callSystem('ls')", "sys.jsx").error.is_some()); + // With the preference on, scripts can write and read anywhere. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("var f = new File({}); f.open('w'); f.writeln('written'); f.close();", q(&dir.join("w.txt")))); + assert_eq!(std::fs::read_to_string(dir.join("w.txt")).unwrap(), "written\n"); + let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); f.readln()", q(&dir.join("secret.txt")))); + assert_eq!(o.result, json!("outside")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn engine_entry_points() { + let mut s = session(); + // script.run reports errors in the result, not as a command failure. + let r = s.execute("script.run", json!({"code": "writeLn('hi'); 1 + 2"})).unwrap(); + assert_eq!(r["ok"], json!(true)); + assert_eq!(r["result"], json!(3)); + assert_eq!(r["output"], json!("hi")); + let r = s.execute("script.run", json!({"code": "\nnope()"})).unwrap(); + assert_eq!(r["ok"], json!(false)); + assert_eq!(r["error"]["line"], json!(2)); + // Scripts can't run scripts. + let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap(); + assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}"); + // File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript. + let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("make.jsx"); + std::fs::write(&f, "app.project.items.addComp('From File', 320, 240, 1, 2, 24);").unwrap(); + s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap(); + assert!(s.project.items.values().any(|i| i.name == "From File")); + std::fs::write(&f, "\n\nbroken(;").unwrap(); + let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("make.jsx (line 3)"), "{e}"); + // JSON command scripts still work. + let j = dir.join("steps.json"); + std::fs::write(&j, r#"[{"command": "comp.new", "params": {"name": "From Steps"}}]"#).unwrap(); + s.execute("file.runScript", json!({"path": j.to_string_lossy()})).unwrap(); + assert!(s.project.items.values().any(|i| i.name == "From Steps")); + // Menu commands by name. + let o = ok(&mut s, "app.findMenuCommandId('New Project') > 0 && app.findMenuCommandId('No Such Thing') === 0"); + assert_eq!(o.result, json!(true)); + let o = ok(&mut s, "app.executeCommand(app.findMenuCommandId('New Project')); app.project.numItems"); + assert_eq!(o.result, json!(0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn console_keeps_variables() { + let mut s = session(); + let req = |code: &'static str| effectcraft_engine::ScriptRequest { code, name: "console", console: true }; + let r = crate::run(&mut s, &req("var counter = 41;")); + assert!(r.error.is_none(), "{r:?}"); + let r = crate::run(&mut s, &req("counter + 1")); + assert_eq!(r.result, json!(42)); + let _ = Value::Null; +} + +#[test] +fn schedule_task_runs_after_the_script() { + let mut s = session(); + let o = ok( + &mut s, + "app.scheduleTask('writeLn(\"later\")', 100, false); var id = app.scheduleTask('writeLn(\"never\")', 10, false); app.cancelTask(id); writeLn('now');", + ); + assert_eq!(o.output, vec!["now", "later"]); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 99bc7c5e..d1a40946 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -34,10 +34,11 @@ pub enum PanelKind { History, Markers, MaskInterpolation, + ScriptConsole, } impl PanelKind { - pub const ALL: [PanelKind; 24] = [ + pub const ALL: [PanelKind; 25] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -62,6 +63,7 @@ impl PanelKind { PanelKind::History, PanelKind::Markers, PanelKind::MaskInterpolation, + PanelKind::ScriptConsole, ]; pub fn title(self) -> &'static str { match self { @@ -89,6 +91,7 @@ impl PanelKind { PanelKind::History => "History", PanelKind::Markers => "Markers", PanelKind::MaskInterpolation => "Mask Interpolation", + PanelKind::ScriptConsole => "Script Console", } } pub fn id(self) -> String { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5eaec2..32d471b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -697,7 +697,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")), - "file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])), + "file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), "file.replaceFootage" => ("path", Ask::Import), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), _ => return None, diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f737502..bdd05305 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -21,6 +21,7 @@ pub mod project; pub mod properties; pub mod puppet_tool; pub mod render_queue; +pub mod script_console; pub mod settings; pub mod shortcut_editor; pub mod text_panels; @@ -82,6 +83,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), PanelKind::MaskInterpolation => anim_tools::mask_interpolation(app, ui, rect), + PanelKind::ScriptConsole => script_console::show(app, ui, rect), PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect), PanelKind::Smoother => anim_tools::smoother(app, ui, rect), PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1824fec5..e9a40e61 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -529,16 +529,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); - if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() { - let sel = app.session.state.project_selection.clone(); - let _ = app.session.edit("Delete Items", None, |proj, st| { - for id in &sel { - proj.items.remove(id); - } - st.project_selection.clear(); - Ok(()) - }); - app.session.sanitize_state(); + if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() + && !app.session.state.project_selection.is_empty() + { + actions.push(("project.delete".into(), json!({}))); } app.auto.add("project.delete", tr, "Delete"); for (id, params) in actions { diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs new file mode 100644 index 00000000..3c258f6a --- /dev/null +++ b/crates/ui-egui/src/panels/script_console.rs @@ -0,0 +1,144 @@ +//! Window ▸ Script Console: a JavaScript REPL over the After Effects-style scripting object model +//! (`app.project`, comps, layers, properties…). Each run goes through the `script.run` command +//! in the console's persistent context, so variables survive between runs; edits are undoable. + +use egui::{Align2, Rect, Sense, pos2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; +use crate::theme::Tokens; + +/// One line of console output. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ConsoleLine { + pub text: String, + /// `input`, `output`, `result` or `error`. + pub kind: String, +} + +/// Script Console state (serde, so agents can read it). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ScriptConsole { + pub input: String, + pub log: Vec, + /// Previously run inputs (Up/Down recall). + #[serde(default)] + pub history: Vec, +} + +impl ScriptConsole { + fn push(&mut self, kind: &str, text: &str) { + for l in text.lines() { + self.log.push(ConsoleLine { text: l.to_string(), kind: kind.to_string() }); + } + if self.log.len() > 2000 { + let extra = self.log.len() - 2000; + self.log.drain(..extra); + } + } + + /// Record a `script.run` result. + pub fn record(&mut self, code: &str, r: &Result) { + self.push("input", &format!("> {}", code.trim())); + match r { + Ok(v) => { + if let Some(o) = v["output"].as_str().filter(|o| !o.is_empty()) { + self.push("output", o); + } + if let Some(e) = v.get("error").filter(|e| !e.is_null()) { + let line = e["line"].as_u64().map(|l| format!("line {l}: ")).unwrap_or_default(); + self.push("error", &format!("{line}{}", e["message"].as_str().unwrap_or("error"))); + } else if !v["result"].is_null() { + self.push("result", &v["result"].to_string()); + } + } + Err(e) => self.push("error", e), + } + } +} + +/// Run the console input. +pub fn run(app: &mut EffectcraftApp, ctx: &egui::Context) { + let code = app.ui.script_console.input.trim().to_string(); + if code.is_empty() { + return; + } + let r = crate::menus::invoke(app, ctx, "script.run", json!({"code": code, "name": "Script Console", "console": true})); + let con = &mut app.ui.script_console; + con.record(&code, &r); + if con.history.last() != Some(&code) { + con.history.push(code); + } + con.input.clear(); +} + +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let input_h = 86.0; + let bar_h = 26.0; + let out_rect = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - input_h - bar_h)); + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(out_rect, 0.0, t.field_bg); + // Output, newest at the bottom. + let line_h = 16.0; + let visible = ((out_rect.height() - 8.0) / line_h).max(1.0) as usize; + let log = &app.ui.script_console.log; + let start = log.len().saturating_sub(visible); + for (i, l) in log[start..].iter().enumerate() { + let color = match l.kind.as_str() { + "error" => t.danger, + "input" => t.text_faint, + "result" => t.accent, + _ => t.text, + }; + p.text(pos2(out_rect.min.x + 8.0, out_rect.min.y + 4.0 + i as f32 * line_h), Align2::LEFT_TOP, &l.text, Tokens::mono(11.5), color); + } + if log.is_empty() { + p.text( + out_rect.center(), + Align2::CENTER_CENTER, + "JavaScript with the After Effects scripting object model — try app.project.numItems", + Tokens::ui(12.0), + t.text_faint, + ); + } + app.auto.add("scriptConsole.output", out_rect, "Script Console output"); + // Input. + let in_rect = Rect::from_min_size(pos2(rect.min.x + 4.0, out_rect.max.y + 4.0), vec2(rect.width() - 8.0, input_h - 8.0)); + let mut run_now = false; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect)); + let te = egui::TextEdit::multiline(&mut app.ui.script_console.input) + .id(egui::Id::new("script-console-input")) + .font(Tokens::mono(12.0)) + .desired_width(in_rect.width()) + .desired_rows(4) + .hint_text("Script… (⌘/Ctrl+Enter runs)"); + let resp = child.add_sized(in_rect.size(), te); + if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command) { + run_now = true; + } + app.auto.add("scriptConsole.input", in_rect, "Script Console input"); + // Buttons. + let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h)); + let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0)); + let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0)); + let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click()); + p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent }); + p.text(run_r.center(), Align2::CENTER_CENTER, "Run", Tokens::ui(12.0), egui::Color32::WHITE); + let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click()); + p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + p.text(clear_r.center(), Align2::CENTER_CENTER, "Clear", Tokens::ui(12.0), t.text); + app.auto.add("scriptConsole.run", run_r, "Run"); + app.auto.add("scriptConsole.clear", clear_r, "Clear"); + if run_resp.clicked() { + run_now = true; + } + if clear_resp.clicked() { + app.ui.script_console.log.clear(); + } + if run_now { + let ctx = ui.ctx().clone(); + run(app, &ctx); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d1350103..7d423b8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -476,6 +476,9 @@ impl Default for AnimToolsState { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, + /// Window ▸ Script Console input, output and history. + #[serde(default)] + pub script_console: crate::panels::script_console::ScriptConsole, /// Tool shown in each toolbar slot. pub slot_tools: Vec, pub workspace: String, @@ -576,6 +579,7 @@ impl Default for UiState { fn default() -> Self { UiState { tool: Tool::Selection, + script_console: Default::default(), slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(), workspace: "Default".into(), dock: crate::dock::workspace("Default"), diff --git a/crates/ui-egui/tests/ui_script_console.rs b/crates/ui-egui/tests/ui_script_console.rs new file mode 100644 index 00000000..a8068c9b --- /dev/null +++ b/crates/ui-egui/tests/ui_script_console.rs @@ -0,0 +1,44 @@ +//! Window ▸ Script Console: the panel shows, runs JavaScript through `script.run` (persistent +//! context) and logs output, results and errors (egui_kittest, UI logic only). + +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui_kittest::Harness; + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let c = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(c)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(2); +} + +#[test] +fn script_console_runs_scripts() { + let s = effectcraft_host::session(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::ScriptConsole); + h.run_steps(3); + for id in ["scriptConsole.input", "scriptConsole.output", "scriptConsole.run", "scriptConsole.clear"] { + assert!(h.state().auto.find(id).is_some(), "missing {id}"); + } + h.state_mut().ui.script_console.input = "var c = app.project.items.addComp('Console', 320, 180, 1, 2, 24); writeLn('made'); c.name".into(); + h.run_steps(1); + click(&mut h, "scriptConsole.run"); + assert!(h.state().session.project.items.values().any(|i| i.name == "Console")); + // Variables persist between runs; errors are logged with their line. + h.state_mut().ui.script_console.input = "c.numLayers + 5".into(); + click(&mut h, "scriptConsole.run"); + h.state_mut().ui.script_console.input = "nope()".into(); + click(&mut h, "scriptConsole.run"); + let log: Vec<(String, String)> = h.state().ui.script_console.log.iter().map(|l| (l.kind.clone(), l.text.clone())).collect(); + assert!(log.contains(&("output".into(), "made".into())), "{log:?}"); + assert!(log.contains(&("result".into(), "\"Console\"".into())), "{log:?}"); + assert!(log.contains(&("result".into(), "5".into())), "{log:?}"); + assert!(log.iter().any(|(k, t)| k == "error" && t.contains("line 1") && t.contains("nope")), "{log:?}"); + assert!(h.state_mut().session.undo(), "console edits are undoable"); + click(&mut h, "scriptConsole.clear"); + assert!(h.state().ui.script_console.log.is_empty()); +} diff --git a/docs/agents.md b/docs/agents.md index 510bc356..40dac3c9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -49,6 +49,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC |---|---| | `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. | | `execute_command {command, params?}` | Run any command (undoable). | +| `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. | | `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | | `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. | | `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | @@ -191,6 +192,19 @@ effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","inclu effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save ``` +Scripts written against After Effects' documented scripting API run with `script`: + +```sh +effectcraft-cli script build.jsx --save-as main.ecproj # empty project unless one is given +effectcraft-cli script --eval 'app.project.item(1).numLayers' main.ecproj --json +effectcraft-cli script tweak.jsx --bridge 9877 # against the live app +``` + +`writeLn`/`$.writeln`/`alert` output is printed, then the value of the last expression. A script +error exits with status 1 and `file:line:col: message`. Scripts may read files only in the +project's folder and may not write files or use the network unless the user turns on Preferences ▸ +Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network. + `file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an agent can check what a player will not show. A `.lottie` path writes a dotLottie archive. diff --git a/docs/architecture.md b/docs/architecture.md index 033ed6cb..dceb0e0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,13 +30,15 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer | | L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express | | L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus | -| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends | +| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) whose edits run engine commands; AE match names | +| L4 | `host` | A fully wired `Session` (media, expressions, scripting, exporter) for the frontends | | L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel | | L5 | `automation` | The MCP server, headless or bridged to the running app | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`, -`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`. +`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`, +`script → engine`. ## 2. Time @@ -182,6 +184,28 @@ Expressions are JavaScript, run by boa, with After Effects' object model (`thisC `time`, `value`, `wiggle`, `loopOut`, vector maths on arrays, and so on). A syntax error keeps the text, disables the expression and shows a warning, like After Effects. +## 5a. Scripting + +`effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one) +with an object model written from the behaviour the After Effects Scripting Guide documents: +`app`, `Project`, `ItemCollection`, `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, +`AVLayer`/`TextLayer`/`ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` +(values, keyframes, eases, expressions), `TextDocument`, `Shape`, `KeyframeEase`, `MarkerValue`, +`RenderQueue`/`RenderQueueItem`/`OutputModule`, `File`/`Folder` and the enums. The model lives in +`prelude.js`; it reads the session through `__query` (JSON snapshots of items, layers, property +nodes, values and keys) and edits only through `__exec`, which runs engine commands, so every +scripted edit is undoable and identical to the UI's. While a script runs the session is moved +into a thread-local the natives borrow (nothing borrowed enters the JS heap); the comp a call +targets is made active for that command only. `app.beginUndoGroup`/`endUndoGroup` fold the undo +steps in between into one named step. Match names (`ADBE Transform Group`, `ADBE Position`, +`ADBE Gaussian Blur 2`, `ADBE Vector Shape - Rect`…) come from a table in `matchnames.rs`; effect +parameters are `-0001…`. `File` reads are limited to the project's folder, and +writes and the network are refused, unless Preferences ▸ Scripting & Expressions ▸ Allow Scripts +to Write Files and Access Network is on. Entry points: the `script.run` command +(`Session::script`, set by the host), File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`; `.json` +command scripts still run as steps), Window ▸ Script Console, `effectcraft-cli script` and the +MCP `run_script` tool. + ## 6. Commands, the UI seam and automation Every action is a command: an id, a label, a menu path, a shortcut, a parameter description, diff --git a/docs/parity.md b/docs/parity.md index d259fec2..02fa2175 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,7 +75,7 @@ library groundwork for mask tracking and Warp Stabilizer. What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker, Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of -the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache, +the new tracking library, SVG/PSD import, WebM, a disk cache, the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and wasm threads. @@ -87,7 +87,7 @@ wasm threads. | Output | 85% | 4.2 | multiple output modules, pre-render, WebM, audio-only | | Audio | 85% | 0.5 | audio to keyframes | | Import | 78% | 4.5 | PSD, SVG as shapes, Lottie | -| Automation | 76% | 3.3 | a JavaScript scripting object model | +| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | | Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) | | Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics | | Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 76ff6fa3..2532f2a2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -39,6 +39,7 @@ const LAYERS: &[(&str, u8)] = &[ ("lottie", 3), ("format", 3), ("engine", 4), + ("script", 4), ("host", 4), ("ui-egui", 5), ("automation", 5), @@ -63,6 +64,8 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("gpu", "render"), ("lottie", "format"), ("host", "engine"), + ("script", "engine"), + ("host", "script"), ("cli", "effectcraft"), ]; From d880746fecf0c211c75983de380708e32a54d590 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:18:11 -0400 Subject: [PATCH 10/10] M14.4: scripting tests for layer/comp settings, import + save, console snapshot; Project.saveAs Co-Authored-By: Claude Opus 5.5 --- crates/host/src/lib.rs | 29 ++++++++++++ crates/script/src/prelude.js | 1 + crates/script/src/tests.rs | 56 +++++++++++++++++++++++ crates/ui-egui/tests/ui_script_console.rs | 24 ++++++++++ 4 files changed, 110 insertions(+) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 5235feca..45e850ff 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -220,6 +220,35 @@ mod tests { assert!(bytes.starts_with(b"GIF89a")); } + /// importFile, layers.add(footage) and project save/open from a script. + #[test] + fn script_imports_footage_and_saves() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script-import"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap(); + let mut s = super::session(); + let code = format!( + r#" + var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10); + c.layers.addSolid([0, 1, 0], "G", 32, 32, 1); + app.run("comp.saveFrameAs", {{comp: c.id, path: {png}}}); + var still = app.project.importFile(new ImportOptions(new File({png}))); + var main = app.project.items.addComp("Main", 64, 64, 1, 1, 10); + var l = main.layers.add(still); + app.project.save(new File({proj})); + [still instanceof FootageItem, still.width, still.mainSource.isStill, l.source.name, app.project.file.name] + "#, + png = q("frame.png"), + proj = q("p.ecproj") + ); + let r = s.execute("script.run", json!({"code": code})).unwrap(); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(r["result"], json!([true, 32, true, "frame.png", "p.ecproj"]), "{r}"); + let r = s.execute("script.run", json!({"code": format!("app.open(new File({})); app.project.numItems", q("p.ecproj"))})).unwrap(); + assert_eq!(r["result"], json!(5), "{r}"); // Src, Solids, G, frame.png, Main + } + #[test] fn expression_syntax_errors_disable_the_expression() { let mut s = super::session(); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 4f359926..c4f256f4 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1571,6 +1571,7 @@ Project.prototype = { else __call("file.save", {}); return true; }, + saveAs: function (file) { return this.save(file); }, saveWithDialog: function () { if (this.file) return this.save(); return false; }, close: function (opt) { if (opt === CloseOptions.SAVE_CHANGES && this.file) this.save(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index f4a4fcb6..458f2b41 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -390,3 +390,59 @@ fn schedule_task_runs_after_the_script() { ); assert_eq!(o.output, vec!["now", "later"]); } + +#[test] +fn layer_switches_timing_and_comp_settings() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("S", 320, 240, 1, 5, 30); + var matte = c.layers.addText("M"); + var fill = c.layers.addSolid([1, 1, 1], "Fill", 320, 240, 1); + fill.moveToEnd(); + fill.setTrackMatte(matte, TrackMatteType.LUMA); + var t1 = fill.trackMatteType == TrackMatteType.LUMA && fill.hasTrackMatte && fill.trackMatteLayer.name == "M"; + fill.stretch = 200; + fill.label = 3; + c.width = 400; + c.duration = 8; + c.bgColor = [0.1, 0.2, 0.3]; + c.time = 2; + c.workAreaStart = 1; + c.workAreaDuration = 3; + var extra = c.layers.addNull(); + extra.remove(); + fill.selected = true; + var sel = c.selectedLayers.length; + fill.transform.opacity.expression = "thisLayer.nope("; + var err = fill.transform.opacity.expressionError; + fill.transform.opacity.expressionEnabled = false; + var copy = c.duplicate(); + var f = app.project.items.addFolder("Bin"); + copy.parentFolder = f; + [t1, fill.stretch, fill.label, c.width, c.duration, c.bgColor[2], c.time, c.workAreaStart, c.workAreaDuration, + c.numLayers, sel, err.length > 0, fill.transform.opacity.expressionEnabled, copy.name, f.numItems, copy.parentFolder.name] + "#, + ); + let v = o.result; + assert_eq!(v[0], json!(true), "{v}"); + assert_eq!(v[1], json!(200)); + assert_eq!(v[2], json!(3)); + assert_eq!(v[3], json!(400)); + assert_eq!(v[4], json!(8)); + assert!((v[5].as_f64().unwrap() - 0.3).abs() < 1e-6); + assert_eq!(v[6], json!(2)); + assert_eq!(v[7], json!(1)); + assert_eq!(v[8], json!(3)); + assert_eq!(v[9], json!(2)); + assert_eq!(v[10], json!(1)); + assert_eq!(v[11], json!(true)); + assert_eq!(v[12], json!(false)); + assert_eq!(v[13], json!("S 2")); + assert_eq!(v[14], json!(1)); + assert_eq!(v[15], json!("Bin")); + // Removing the copy through the object model. + let o = ok(&mut s, "var n = app.project.numItems; app.project.item(1).remove(); n - app.project.numItems"); + assert_eq!(o.result, json!(1)); +} diff --git a/crates/ui-egui/tests/ui_script_console.rs b/crates/ui-egui/tests/ui_script_console.rs index a8068c9b..526a5293 100644 --- a/crates/ui-egui/tests/ui_script_console.rs +++ b/crates/ui-egui/tests/ui_script_console.rs @@ -42,3 +42,27 @@ fn script_console_runs_scripts() { click(&mut h, "scriptConsole.clear"); assert!(h.state().ui.script_console.log.is_empty()); } + +/// Headless look at the panel (wgpu offscreen); run with +/// `SC_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test ui_script_console -- --ignored`. +#[test] +#[ignore] +fn script_console_snapshot() { + let s = effectcraft_host::session(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::ScriptConsole); + h.run_steps(2); + for code in ["var c = app.project.items.addComp('Console', 320, 180, 1, 2, 24); writeLn('made ' + c.name); c.id", "c.layers.addText('Hi').name", "nope()"] { + h.state_mut().ui.script_console.input = code.into(); + h.run_steps(1); + click(&mut h, "scriptConsole.run"); + } + h.state_mut().ui.script_console.input = "app.project.numItems".into(); + h.run_steps(3); + let img = h.render().expect("render"); + let out = std::env::var("SC_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/script_console.png").into()); + if let Some(d) = std::path::Path::new(&out).parent() { + std::fs::create_dir_all(d).unwrap(); + } + img.save(&out).unwrap(); +}