diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs new file mode 100644 index 00000000..855fd10e --- /dev/null +++ b/crates/effects/src/audio_fx.rs @@ -0,0 +1,682 @@ +//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Delay, Flange & Chorus, +//! 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 +//! composition mixdown (`effectcraft_render::audio`), which preview playback and export share. +//! +//! The mixdown renders in short blocks. To keep the result independent of block boundaries +//! every effect is stateless across calls: the mixer feeds [`preroll`] seconds of the layer's +//! audio before the block, runs the chain from a cleared state and keeps the tail. +//! +//! 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. + +use std::f64::consts::PI; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; + +use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec) -> EffectSpec { + EffectSpec { id, name, category: "Audio", params, render: passthrough, gpu: false, float: true } +} + +fn passthrough(_: &EffectCtx, b: Buf) -> Buf { + b +} + +fn pct(default: f64) -> (Value, ParamUi) { + (num(default), slider(0.0, 100.0, 0.0, 100.0, 2)) +} + +fn pp(id: &'static str, name: &'static str, v: (Value, ParamUi)) -> crate::ParamSpec { + p(id, name, v.0, v.1) +} + +pub fn specs() -> Vec { + const BANDS: [[&str; 8]; 3] = [ + ["band1Enable", "Band 1 Enable", "band1Frequency", "Band 1 Frequency", "band1Bandwidth", "Band 1 Bandwidth", "band1BoostCut", "Band 1 Boost/Cut"], + ["band2Enable", "Band 2 Enable", "band2Frequency", "Band 2 Frequency", "band2Bandwidth", "Band 2 Bandwidth", "band2BoostCut", "Band 2 Boost/Cut"], + ["band3Enable", "Band 3 Enable", "band3Frequency", "Band 3 Frequency", "band3Bandwidth", "Band 3 Bandwidth", "band3BoostCut", "Band 3 Boost/Cut"], + ]; + let mut eq = vec![]; + for (b, (f, on)) in BANDS.iter().zip([(200.0, true), (1000.0, false), (5000.0, false)]) { + eq.push(p(b[0], b[1], Value::Bool(on), ParamUi::Checkbox)); + eq.push(p(b[2], b[3], num(f), slider(20.0, 20000.0, 20.0, 20000.0, 1))); + eq.push(p(b[4], b[5], num(30.0), slider(1.0, 100.0, 1.0, 100.0, 1))); + eq.push(p(b[6], b[7], num(0.0), slider(-24.0, 24.0, -24.0, 24.0, 2))); + } + vec![ + spec("ec.audio.backwards", "Backwards", vec![p("swapChannels", "Swap Channels", Value::Bool(false), ParamUi::Checkbox)]), + spec( + "ec.audio.basstreble", + "Bass & Treble", + vec![ + p("bass", "Bass", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)), + p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)), + ], + ), + spec( + "ec.audio.delay", + "Delay", + vec![ + p("delayTime", "Delay Time (milliseconds)", num(500.0), slider(1.0, 10000.0, 1.0, 2000.0, 2)), + pp("delayAmount", "Delay Amount", pct(50.0)), + pp("feedback", "Feedback", pct(50.0)), + pp("dryOut", "Dry Out", pct(75.0)), + pp("wetOut", "Wet Out", pct(75.0)), + ], + ), + spec( + "ec.audio.flangechorus", + "Flange & Chorus", + vec![ + p("voiceSeparationTime", "Voice Separation Time (ms)", num(3.0), slider(0.0, 100.0, 0.0, 50.0, 2)), + p("voices", "Voices", num(1.0), slider(1.0, 8.0, 1.0, 8.0, 0)), + p("modulationRate", "Modulation Rate", num(0.4), slider(0.0, 10.0, 0.0, 5.0, 2)), + pp("modulationDepth", "Modulation Depth", pct(50.0)), + p("voicePhaseChange", "Voice Phase Change", num(0.0), ParamUi::Angle), + p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox), + p("stereoVoices", "Stereo Voices", Value::Bool(false), ParamUi::Checkbox), + pp("dryOut", "Dry Out", pct(50.0)), + pp("wetOut", "Wet Out", pct(50.0)), + ], + ), + spec( + "ec.audio.highlowpass", + "High-Low Pass", + vec![ + p("filterOptions", "Filter Options", Value::Enum(0), popup(&["High Pass", "Low Pass"])), + p("cutoffFrequency", "Cutoff Frequency", num(2000.0), slider(20.0, 20000.0, 20.0, 20000.0, 1)), + pp("dryOut", "Dry Out", pct(0.0)), + pp("wetOut", "Wet Out", pct(100.0)), + ], + ), + spec( + "ec.audio.modulator", + "Modulator", + vec![ + p("modulationType", "Modulation Type", Value::Enum(0), popup(&["Sine", "Triangle"])), + p("modulationRate", "Modulation Rate", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + pp("modulationDepth", "Modulation Depth", pct(5.0)), + pp("amplitudeModulation", "Amplitude Modulation", pct(0.0)), + ], + ), + spec("ec.audio.parametriceq", "Parametric EQ", eq), + spec( + "ec.audio.reverb", + "Reverb", + vec![ + p("reverbTime", "Reverb Time (milliseconds)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)), + pp("diffusion", "Diffusion", pct(75.0)), + pp("decay", "Decay", pct(25.0)), + pp("brightness", "Brightness", pct(10.0)), + pp("dryOut", "Dry Out", pct(90.0)), + pp("wetOut", "Wet Out", pct(10.0)), + ], + ), + spec( + "ec.audio.stereomixer", + "Stereo Mixer", + vec![ + p("leftLevel", "Left Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)), + p("rightLevel", "Right Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)), + p("leftPan", "Left Pan", num(-100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)), + p("rightPan", "Right Pan", num(100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)), + p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox), + ], + ), + spec( + "ec.audio.tone", + "Tone", + vec![ + p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Sine", "Triangle", "Saw", "Square"])), + p("frequency1", "Frequency 1", num(440.0), slider(0.0, 22000.0, 0.0, 2000.0, 2)), + p("frequency2", "Frequency 2", num(493.88), slider(0.0, 22000.0, 0.0, 2000.0, 2)), + p("frequency3", "Frequency 3", num(587.33), slider(0.0, 22000.0, 0.0, 2000.0, 2)), + p("frequency4", "Frequency 4", num(659.26), slider(0.0, 22000.0, 0.0, 2000.0, 2)), + p("frequency5", "Frequency 5", num(783.99), slider(0.0, 22000.0, 0.0, 2000.0, 2)), + pp("level", "Level", pct(20.0)), + ], + ), + ] +} + +/// Is `id` an audio effect (applied by the mixdown rather than the compositor)? +pub fn is_audio_effect(id: &str) -> bool { + id.starts_with("ec.audio.") +} + +/// Does the effect produce sound on its own (so a layer without audio becomes audible)? +pub fn generates_audio(id: &str) -> bool { + id == "ec.audio.tone" +} + +/// Does the effect play the layer's audio backwards (a time mapping the mixer applies)? +pub fn reverses(id: &str) -> bool { + id == "ec.audio.backwards" +} + +/// Seconds of input history the effect needs before a block to reach the state it would have +/// had when processing continuously. +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.delay" => { + let d = params.f("delayTime").max(1.0) / 1000.0; + let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99); + let taps = if fb > 1e-3 { (1e-3f64.ln() / fb.ln()).ceil() + 1.0 } else { 1.0 }; + (d * taps).min(10.0) + } + "ec.audio.reverb" => { + let (fb, _, _, pre) = reverb_params(params); + // T60 of the longest comb (~37 ms) with loop gain fb, plus the pre-delay. + let t60 = if fb > 1e-3 { -3.0 * 0.0367 / fb.log10() } else { 0.05 }; + (t60 + pre).min(4.0) + } + _ => 0.0, + } +} + +/// Run audio effect `id` over interleaved stereo `buf` (L R L R …) at `rate` Hz, from a cleared +/// state. `start` is the layer time (seconds) of the first sample (oscillator phase). +pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64) { + if rate == 0 || buf.len() < 2 { + return; + } + let sr = rate as f64; + match id { + "ec.audio.backwards" => { + if params.b("swapChannels") { + buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1)); + } + } + "ec.audio.basstreble" => { + let bass = params.f("bass") / 100.0 * 12.0; + let treble = params.f("treble") / 100.0 * 12.0; + if bass != 0.0 { + run_biquad(buf, Biquad::low_shelf(sr, 200.0, bass)); + } + if treble != 0.0 { + run_biquad(buf, Biquad::high_shelf(sr, 4000.0, treble)); + } + } + "ec.audio.highlowpass" => { + let fc = params.f("cutoffFrequency").clamp(1.0, sr * 0.49); + let q = std::f64::consts::FRAC_1_SQRT_2; + let bq = if params.e("filterOptions") == 1 { Biquad::low_pass(sr, fc, q) } else { Biquad::high_pass(sr, fc, q) }; + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + let orig = buf.to_vec(); + run_biquad(buf, bq); + mix_dry_wet(buf, &orig, dry, wet); + } + "ec.audio.parametriceq" => { + for n in 1..=3 { + if !params.b(&format!("band{n}Enable")) { + continue; + } + let g = params.f(&format!("band{n}BoostCut")); + if g == 0.0 { + continue; + } + let f = params.f(&format!("band{n}Frequency")).clamp(1.0, sr * 0.49); + let bw = (params.f(&format!("band{n}Bandwidth")) / 100.0 * 3.0).max(0.05); + run_biquad(buf, Biquad::peaking(sr, f, bw, g)); + } + } + "ec.audio.delay" => delay(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), + "ec.audio.stereomixer" => stereo_mixer(params, buf), + "ec.audio.tone" => tone(params, buf, sr, start), + _ => {} + } +} + +fn mix_dry_wet(buf: &mut [f32], dry_src: &[f32], dry: f32, wet: f32) { + for (o, d) in buf.iter_mut().zip(dry_src) { + *o = *o * wet + *d * dry; + } +} + +// ---------------------------------------------------------------- biquads (RBJ cookbook) + +/// Normalised second-order section coefficients (a0 = 1). +#[derive(Clone, Copy, Debug)] +pub struct Biquad { + pub b: [f64; 3], + pub a: [f64; 2], +} + +impl Biquad { + fn norm(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Biquad { + Biquad { b: [b0 / a0, b1 / a0, b2 / a0], a: [a1 / a0, a2 / a0] } + } + pub fn low_pass(sr: f64, f0: f64, q: f64) -> Biquad { + let w = 2.0 * PI * f0 / sr; + let (s, c) = w.sin_cos(); + let al = s / (2.0 * q); + Self::norm((1.0 - c) / 2.0, 1.0 - c, (1.0 - c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al) + } + pub fn high_pass(sr: f64, f0: f64, q: f64) -> Biquad { + let w = 2.0 * PI * f0 / sr; + let (s, c) = w.sin_cos(); + let al = s / (2.0 * q); + Self::norm((1.0 + c) / 2.0, -(1.0 + c), (1.0 + c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al) + } + /// Peaking EQ with bandwidth `bw` in octaves and gain in dB. + pub fn peaking(sr: f64, f0: f64, bw: f64, db: f64) -> Biquad { + let a = 10f64.powf(db / 40.0); + let w = 2.0 * PI * f0 / sr; + let (s, c) = w.sin_cos(); + let al = s * (2f64.ln() / 2.0 * bw * w / s).sinh(); + Self::norm(1.0 + al * a, -2.0 * c, 1.0 - al * a, 1.0 + al / a, -2.0 * c, 1.0 - al / a) + } + /// Shelf with slope S = 1. + pub fn low_shelf(sr: f64, f0: f64, db: f64) -> Biquad { + let a = 10f64.powf(db / 40.0); + let w = 2.0 * PI * f0 / sr; + let (s, c) = w.sin_cos(); + let al = s / 2.0 * std::f64::consts::SQRT_2; + let sq = 2.0 * a.sqrt() * al; + Self::norm( + a * ((a + 1.0) - (a - 1.0) * c + sq), + 2.0 * a * ((a - 1.0) - (a + 1.0) * c), + a * ((a + 1.0) - (a - 1.0) * c - sq), + (a + 1.0) + (a - 1.0) * c + sq, + -2.0 * ((a - 1.0) + (a + 1.0) * c), + (a + 1.0) + (a - 1.0) * c - sq, + ) + } + pub fn high_shelf(sr: f64, f0: f64, db: f64) -> Biquad { + let a = 10f64.powf(db / 40.0); + let w = 2.0 * PI * f0 / sr; + let (s, c) = w.sin_cos(); + let al = s / 2.0 * std::f64::consts::SQRT_2; + let sq = 2.0 * a.sqrt() * al; + Self::norm( + a * ((a + 1.0) + (a - 1.0) * c + sq), + -2.0 * a * ((a - 1.0) + (a + 1.0) * c), + a * ((a + 1.0) + (a - 1.0) * c - sq), + (a + 1.0) - (a - 1.0) * c + sq, + 2.0 * ((a - 1.0) - (a + 1.0) * c), + (a + 1.0) - (a - 1.0) * c - sq, + ) + } +} + +/// Direct form I over both channels of an interleaved buffer. +fn run_biquad(buf: &mut [f32], q: Biquad) { + for ch in 0..2 { + let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0); + for i in (ch..buf.len()).step_by(2) { + let x = buf[i] as f64; + let y = q.b[0] * x + q.b[1] * x1 + q.b[2] * x2 - q.a[0] * y1 - q.a[1] * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + buf[i] = y as f32; + } + } +} + +// ---------------------------------------------------------------- delay lines + +/// Linear-interpolated read `d` samples (fractional) behind position `i` of channel `ch`. +fn read_frac(src: &[f32], i: usize, ch: usize, d: f64) -> f32 { + let pos = i as f64 - d; + if pos < 0.0 { + return 0.0; + } + let p0 = pos.floor() as usize; + let f = (pos - p0 as f64) as f32; + let a = src[p0 * 2 + ch]; + let b = if p0 < i { src[(p0 + 1) * 2 + ch] } else { a }; + a + (b - a) * f +} + +fn delay(params: &Params, buf: &mut [f32], sr: f64) { + let d = ((params.f("delayTime").max(0.0) / 1000.0 * sr).round() as usize).max(1); + let amount = (params.f("delayAmount") / 100.0) as f32; + let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99) as f32; + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + let n = buf.len() / 2; + // Echo line: e[i] = x[i-d] + fb * e[i-d]; out = dry*x + wet*amount*e. + let mut e = vec![0.0f32; buf.len()]; + for i in d..n { + for ch in 0..2 { + e[i * 2 + ch] = buf[(i - d) * 2 + ch] + fb * e[(i - d) * 2 + ch]; + } + } + for (o, v) in buf.iter_mut().zip(&e) { + *o = *o * dry + v * wet * amount; + } +} + +fn lfo(kind: u32, phase: f64) -> f64 { + let ph = phase.rem_euclid(1.0); + match kind { + 1 => 1.0 - 4.0 * (ph - 0.5).abs(), // triangle, −1..1 + _ => (2.0 * PI * ph).sin(), + } +} + +fn flange_chorus(params: &Params, buf: &mut [f32], sr: f64, start: f64) { + let sep = params.f("voiceSeparationTime").max(0.0) / 1000.0 * sr; + let voices = params.f("voices").round().clamp(1.0, 8.0) as usize; + let rate = params.f("modulationRate").max(0.0); + let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0); + let vphase = params.f("voicePhaseChange") / 360.0; + let inv = if params.b("invertPhase") { -1.0 } else { 1.0 }; + let stereo = params.b("stereoVoices"); + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + let src = buf.to_vec(); + let n = buf.len() / 2; + for i in 0..n { + let t = start + i as f64 / sr; + let mut acc = [0.0f32; 2]; + let mut cnt = [0usize; 2]; + for v in 0..voices { + let base = sep * (v + 1) as f64; + let d = (base * (1.0 + depth * lfo(0, rate * t + vphase * v as f64))).max(0.0); + for ch in 0..2 { + if stereo && voices > 1 && v % 2 != ch { + continue; + } + acc[ch] += read_frac(&src, i, ch, d); + cnt[ch] += 1; + } + } + for ch in 0..2 { + let w = if cnt[ch] > 0 { acc[ch] / cnt[ch] as f32 } else { 0.0 }; + buf[i * 2 + ch] = src[i * 2 + ch] * dry + inv * w * wet; + } + } +} + +fn modulator(params: &Params, buf: &mut [f32], sr: f64, start: f64) { + let kind = params.e("modulationType"); + let rate = params.f("modulationRate").max(0.0); + // Depth: vibrato as a modulated delay of up to 5 ms; amplitude: tremolo. + let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0) * 0.005 * sr; + let am = (params.f("amplitudeModulation") / 100.0).clamp(0.0, 1.0); + let src = buf.to_vec(); + let n = buf.len() / 2; + for i in 0..n { + let t = start + i as f64 / sr; + let l = lfo(kind, rate * t); + let d = depth * (1.0 + l) * 0.5; + let g = (1.0 - am * (1.0 - l) * 0.5) as f32; + for ch in 0..2 { + let x = if depth > 0.0 { read_frac(&src, i, ch, d) } else { src[i * 2 + ch] }; + buf[i * 2 + ch] = x * g; + } + } +} + +/// (comb feedback, damping, all-pass gain, pre-delay seconds) +fn reverb_params(params: &Params) -> (f64, f64, f64, f64) { + let decay = (params.f("decay") / 100.0).clamp(0.0, 1.0); + let fb = 0.7 + decay * 0.28; + let damp = (1.0 - params.f("brightness") / 100.0).clamp(0.0, 1.0) * 0.6; + let ap = 0.3 + 0.4 * (params.f("diffusion") / 100.0).clamp(0.0, 1.0); + let pre = params.f("reverbTime").max(0.0) / 1000.0; + (fb, damp, ap, pre) +} + +fn reverb(params: &Params, buf: &mut [f32], sr: f64) { + const COMBS: [usize; 8] = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617]; + const ALLPASS: [usize; 4] = [556, 441, 341, 225]; + const SPREAD: usize = 23; + let (fb, damp, apg, pre) = reverb_params(params); + let dry = (params.f("dryOut") / 100.0) as f32; + let wet = (params.f("wetOut") / 100.0) as f32; + let k = sr / 44100.0; + let pre_n = (pre * sr).round() as usize; + let n = buf.len() / 2; + let src = buf.to_vec(); + for ch in 0..2 { + let spread = if ch == 1 { SPREAD } else { 0 }; + let mut combs: Vec<(Vec, usize, f64)> = COMBS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0, 0.0)).collect(); + let mut aps: Vec<(Vec, usize)> = ALLPASS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0)).collect(); + for i in 0..n { + let x = if i >= pre_n { (src[(i - pre_n) * 2] as f64 + src[(i - pre_n) * 2 + 1] as f64) * 0.5 * 0.03 } else { 0.0 }; + let mut out = 0.0; + for (line, pos, filt) in combs.iter_mut() { + let y = line[*pos]; + *filt = y * (1.0 - damp) + *filt * damp; + line[*pos] = x + *filt * fb; + *pos = (*pos + 1) % line.len(); + out += y; + } + for (line, pos) in aps.iter_mut() { + let b = line[*pos]; + let y = -out + b; + line[*pos] = out + b * apg; + *pos = (*pos + 1) % line.len(); + out = y; + } + buf[i * 2 + ch] = src[i * 2 + ch] * dry + out as f32 * wet * 3.0; + } + } +} + +fn stereo_mixer(params: &Params, buf: &mut [f32]) { + let ll = (params.f("leftLevel") / 100.0) as f32; + let rl = (params.f("rightLevel") / 100.0) as f32; + // Linear pan law: −100 = all left, 0 = both at full, +100 = all right. + let pan = |p: f64| -> (f32, f32) { + let p = (p / 100.0).clamp(-1.0, 1.0); + ((1.0 - p).min(1.0) as f32, (1.0 + p).min(1.0) as f32) + }; + let (l_to_l, l_to_r) = pan(params.f("leftPan")); + let (r_to_l, r_to_r) = pan(params.f("rightPan")); + let inv = if params.b("invertPhase") { -1.0 } else { 1.0 }; + for f in buf.chunks_exact_mut(2) { + let (l, r) = (f[0] * ll, f[1] * rl); + f[0] = (l * l_to_l + r * r_to_l) * inv; + f[1] = (l * l_to_r + r * r_to_r) * inv; + } +} + +fn tone(params: &Params, buf: &mut [f32], sr: f64, start: f64) { + let kind = params.e("waveformOptions"); + let level = (params.f("level") / 100.0) as f32; + let freqs: Vec = (1..=5).map(|i| params.f(&format!("frequency{i}"))).filter(|f| *f > 0.0).collect(); + let n = buf.len() / 2; + for i in 0..n { + let t = start + i as f64 / sr; + let mut v = 0.0f64; + for f in &freqs { + let ph = (f * t).rem_euclid(1.0); + v += match kind { + 1 => lfo(1, ph - 0.25), + 2 => 2.0 * ph - 1.0, + 3 => { + if ph < 0.5 { + 1.0 + } else { + -1.0 + } + } + _ => (2.0 * PI * ph).sin(), + }; + } + let v = v as f32 * level; + buf[i * 2] += v; + buf[i * 2 + 1] += v; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn defaults(id: &str) -> Params { + let s = crate::find(id).unwrap(); + Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() } + } + + fn sine(freq: f64, sr: f64, n: usize) -> Vec { + (0..n) + .flat_map(|i| { + let v = (2.0 * PI * freq * i as f64 / sr).sin() as f32; + [v, v] + }) + .collect() + } + + fn rms(b: &[f32], from: usize) -> f32 { + let s: f32 = b[from * 2..].iter().map(|v| v * v).sum(); + (s / (b.len() - from * 2) as f32).sqrt() + } + + #[test] + fn registered_as_audio() { + for id in [ + "ec.audio.backwards", + "ec.audio.basstreble", + "ec.audio.delay", + "ec.audio.flangechorus", + "ec.audio.highlowpass", + "ec.audio.modulator", + "ec.audio.parametriceq", + "ec.audio.reverb", + "ec.audio.stereomixer", + "ec.audio.tone", + ] { + let s = crate::find(id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(s.category, "Audio"); + assert!(is_audio_effect(id)); + } + } + + #[test] + fn high_low_pass_attenuates() { + let sr = 48000.0; + let mut p = defaults("ec.audio.highlowpass"); + p.values.insert("filterOptions".into(), Value::Enum(1)); + p.values.insert("cutoffFrequency".into(), num(500.0)); + let mut hi = sine(8000.0, sr, 4800); + process("ec.audio.highlowpass", &p, &mut hi, 48000, 0.0); + let mut lo = sine(100.0, sr, 4800); + process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0); + assert!(rms(&hi, 1000) < 0.01, "{}", rms(&hi, 1000)); + assert!(rms(&lo, 1000) > 0.6, "{}", rms(&lo, 1000)); + // High pass does the opposite. + p.values.insert("filterOptions".into(), Value::Enum(0)); + let mut lo = sine(50.0, sr, 9600); + process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0); + assert!(rms(&lo, 2000) < 0.02); + } + + #[test] + fn delay_offsets_impulse() { + let mut p = defaults("ec.audio.delay"); + p.values.insert("delayTime".into(), num(10.0)); + p.values.insert("feedback".into(), num(0.0)); + let mut b = vec![0.0f32; 2000]; + b[0] = 1.0; + b[1] = 1.0; + process("ec.audio.delay", &p, &mut b, 10000, 0.0); + // dry 75 %, then the echo at 100 samples: wet 75 % × amount 50 %. + assert!((b[0] - 0.75).abs() < 1e-6); + assert!((b[200] - 0.375).abs() < 1e-6 && (b[201] - 0.375).abs() < 1e-6); + assert!(b[2..200].iter().all(|v| *v == 0.0)); + assert!(preroll("ec.audio.delay", &p) >= 0.01); + } + + #[test] + fn stereo_mixer_pans() { + let mut p = defaults("ec.audio.stereomixer"); + let mut b = vec![1.0, 0.0, 1.0, 0.0]; + process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0); + assert_eq!(b, vec![1.0, 0.0, 1.0, 0.0], "defaults are identity"); + p.values.insert("leftPan".into(), num(100.0)); + let mut b = vec![1.0, 0.0]; + process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0); + assert_eq!(b, vec![0.0, 1.0]); + p.values.insert("leftPan".into(), num(0.0)); + p.values.insert("leftLevel".into(), num(50.0)); + let mut b = vec![1.0, 0.0]; + process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0); + assert_eq!(b, vec![0.5, 0.5]); + } + + #[test] + fn tone_generates_frequency() { + let mut p = defaults("ec.audio.tone"); + p.values.insert("frequency1".into(), num(1000.0)); + for i in 2..=5 { + p.values.insert(format!("frequency{i}"), num(0.0)); + } + let sr = 48000; + let mut b = vec![0.0f32; 48000 * 2]; + process("ec.audio.tone", &p, &mut b, sr, 0.0); + let left: Vec = b.iter().step_by(2).copied().collect(); + let crossings = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count(); + assert!((crossings as i64 - 1000).abs() <= 1, "{crossings}"); + let peak = left.iter().fold(0.0f32, |m, v| m.max(v.abs())); + assert!((peak - 0.2).abs() < 1e-3, "{peak}"); + } + + #[test] + fn bass_treble_eq_reverb_modulator_flange() { + let sr = 48000.0; + // Bass boost raises 60 Hz, treble cut lowers 10 kHz. + let mut p = defaults("ec.audio.basstreble"); + p.values.insert("bass".into(), num(100.0)); + p.values.insert("treble".into(), num(-100.0)); + let mut lo = sine(60.0, sr, 9600); + process("ec.audio.basstreble", &p, &mut lo, 48000, 0.0); + assert!(rms(&lo, 4000) > 0.707 * 3.0, "{}", rms(&lo, 4000)); + let mut hi = sine(10000.0, sr, 9600); + process("ec.audio.basstreble", &p, &mut hi, 48000, 0.0); + assert!(rms(&hi, 4000) < 0.707 * 0.4, "{}", rms(&hi, 4000)); + // Parametric EQ boost at its centre. + let mut p = defaults("ec.audio.parametriceq"); + p.values.insert("band1BoostCut".into(), num(12.0)); + p.values.insert("band1Frequency".into(), num(1000.0)); + let mut s = sine(1000.0, sr, 9600); + process("ec.audio.parametriceq", &p, &mut s, 48000, 0.0); + assert!((rms(&s, 4000) * std::f32::consts::SQRT_2 - 3.98).abs() < 0.2, "{}", rms(&s, 4000) * std::f32::consts::SQRT_2); + // Reverb leaves a tail after an impulse. + let p = defaults("ec.audio.reverb"); + let mut b = vec![0.0f32; 48000]; + b[0] = 1.0; + b[1] = 1.0; + process("ec.audio.reverb", &p, &mut b, 48000, 0.0); + assert!(b[2 * 12000..].iter().any(|v| v.abs() > 1e-5)); + assert!(b.iter().all(|v| v.is_finite() && v.abs() < 2.0)); + // Modulator with amplitude modulation dips the level; flange keeps it bounded. + let mut p = defaults("ec.audio.modulator"); + p.values.insert("amplitudeModulation".into(), num(100.0)); + let mut b = vec![1.0f32; 9600]; + process("ec.audio.modulator", &p, &mut b, 48000, 0.0); + assert!(b.iter().any(|v| *v < 0.05) && b.iter().any(|v| *v > 0.95)); + let p = defaults("ec.audio.flangechorus"); + let mut b = sine(440.0, sr, 4800); + process("ec.audio.flangechorus", &p, &mut b, 48000, 0.0); + assert!(b.iter().all(|v| v.abs() <= 1.0 + 1e-4)); + } + + #[test] + fn backwards_swaps_channels() { + let mut p = defaults("ec.audio.backwards"); + p.values.insert("swapChannels".into(), Value::Bool(true)); + let mut b = vec![1.0, 2.0]; + process("ec.audio.backwards", &p, &mut b, 48000, 0.0); + assert_eq!(b, vec![2.0, 1.0]); + assert!(reverses("ec.audio.backwards")); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6b8b7a97..923a24e4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -6,6 +6,7 @@ //! [`instantiate`]), so parameters animate, take expressions and are addressable like any other //! property. +pub mod audio_fx; mod blur2; mod channel; mod channel2; @@ -33,6 +34,7 @@ mod sim2; mod stylize2; mod stylize3; mod textfx; +mod time_fx; mod transition; mod transition2; pub mod util; @@ -161,6 +163,18 @@ pub trait EffectHost: Sync { /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option>; + /// The effect's own layer at another **layer time** `layer_time` (seconds): its source with + /// masks applied, followed by the first `effects` effects of its stack (0 = none, which is + /// what After Effects' Time effects see). `None` when unavailable (no host, recursion + /// limit, adjustment layers). Time effects use this to read neighbouring frames. + fn self_at(&self, _layer_time: f64, _effects: usize) -> Option { + None + } + /// Layer `id` of the same composition at comp time `comp_time` (seconds), like + /// [`EffectHost::layer`] but at another time. + fn layer_at(&self, _id: u64, _comp_time: f64, _masks_and_effects: bool) -> Option { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). @@ -173,6 +187,8 @@ pub struct EffectEnv<'a> { pub comp_time: f64, /// Composition frame rate (0 when unknown). pub frame_rate: f64, + /// Index of the running effect in its layer's stack (for [`EffectHost::self_at`]). + pub effect_index: usize, } /// What an effect gets to render with. @@ -268,6 +284,8 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(noise2::specs()); v.extend(color3::specs()); v.extend(obsolete::specs()); + v.extend(time_fx::specs()); + v.extend(audio_fx::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); v }) @@ -319,6 +337,15 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", + // Time effects read neighbouring frames of the layer. + "ec.time.echo", + "ec.time.posterizetime", + "ec.time.timedifference", + "ec.time.timedisplacement", + "ec.time.timewarp", + "ec.time.ccforcemotionblur", + "ec.time.ccwidetime", + "ec.time.pixelmotionblur", ]; /// See [`TIME_DEPENDENT`]. diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs new file mode 100644 index 00000000..ee0f3e3c --- /dev/null +++ b/crates/effects/src/time_fx.rs @@ -0,0 +1,590 @@ +//! Time effects (Effect > Time): Echo, Posterize Time, Time Difference, Time Displacement, +//! Timewarp, CC Force Motion Blur, CC Wide Time and Pixel Motion Blur. +//! +//! These read the layer at *other* layer times through [`EffectHost::self_at`] with zero +//! preceding effects: like After Effects' Time effects they see the layer's source with its +//! masks and ignore effects applied before them (precompose to include those). Without a host +//! (tests, thumbnails) they pass their input through. +//! +//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by +//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet. +//! +//! [`EffectHost::self_at`]: crate::EffectHost::self_at + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{fit_layer, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Time", params, render, gpu: false, float: true } +} + +pub fn specs() -> Vec { + vec![ + spec( + "ec.time.echo", + "Echo", + vec![ + p("echoTime", "Echo Time (seconds)", num(-1.0 / 30.0), slider(-30.0, 30.0, -5.0, 5.0, 3)), + p("numberOfEchoes", "Number Of Echoes", num(1.0), slider(0.0, 255.0, 0.0, 30.0, 0)), + p("startingIntensity", "Starting Intensity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("decay", "Decay", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p( + "echoOperator", + "Echo Operator", + Value::Enum(0), + popup(&["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]), + ), + ], + echo, + ), + spec("ec.time.posterizetime", "Posterize Time", vec![p("frameRate", "Frame Rate", num(12.0), slider(0.1, 99.0, 1.0, 60.0, 1))], posterize_time), + spec( + "ec.time.timedifference", + "Time Difference", + vec![ + p("targetLayer", "Target", Value::Layer(None), ParamUi::Layer), + p("timeOffset", "Time Offset (sec)", num(0.0), slider(-30.0, 30.0, -5.0, 5.0, 3)), + p("contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("absoluteDifference", "Absolute Difference", Value::Bool(true), ParamUi::Checkbox), + p( + "alphaChannel", + "Alpha Channel", + Value::Enum(0), + popup(&[ + "Original", + "Target", + "Blend", + "Max", + "Full On", + "Lightness of Result", + "Max of Result", + "Alpha Difference", + "Alpha Difference Only", + ]), + ), + ], + time_difference, + ), + spec( + "ec.time.timedisplacement", + "Time Displacement", + vec![ + p("displacementMapLayer", "Time Displacement Layer", Value::Layer(None), ParamUi::Layer), + p("maxDisplacementTime", "Max Displacement Time [sec]", num(1.0), slider(-30.0, 30.0, -5.0, 5.0, 2)), + p("timeResolution", "Time Resolution [fps]", num(60.0), slider(1.0, 999.0, 1.0, 120.0, 1)), + p("stretchMap", "If Layer Sizes Differ: Stretch Map to Fit", Value::Bool(true), ParamUi::Checkbox), + ], + time_displacement, + ), + spec( + "ec.time.timewarp", + "Timewarp", + vec![ + p("method", "Method", Value::Enum(2), popup(&["Whole Frames", "Frame Mix", "Pixel Motion"])), + p("adjustTimeBy", "Adjust Time By", Value::Enum(0), popup(&["Speed", "Source Frame"])), + p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)), + p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)), + p("vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox), + p("shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])), + p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), + p("shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + ], + timewarp, + ), + spec( + "ec.time.ccforcemotionblur", + "CC Force Motion Blur", + vec![ + p("motionBlurLevels", "Motion Blur Levels", num(8.0), slider(2.0, 64.0, 2.0, 32.0, 0)), + p("overrideShutterAngle", "Override Shutter Angle", Value::Bool(true), ParamUi::Checkbox), + p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 3600.0, 0.0, 360.0, 1)), + p("nativeMotionBlur", "Native Motion Blur", Value::Enum(0), popup(&["Off", "On"])), + ], + force_motion_blur, + ), + spec( + "ec.time.ccwidetime", + "CC Wide Time", + vec![ + p("forwardSteps", "Forward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)), + p("backwardSteps", "Backward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)), + p("nativeMotionBlur", "Native Motion Blur", Value::Enum(0), popup(&["Off", "On"])), + ], + wide_time, + ), + spec( + "ec.time.pixelmotionblur", + "Pixel Motion Blur", + vec![ + p("shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])), + p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), + p("shutterSamples", "Shutter Samples", num(16.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + pixel_motion_blur, + ), + ] +} + +// ---------------------------------------------------------------- frame access + +/// Frames of the layer at several layer times, resampled onto one common pixel grid. +struct Frames { + /// Template buffer (geometry of the output: offset/scale/size). + grid: Buf, + imgs: Vec, +} + +/// Fetch the layer (source + masks) at layer times `times`. `None` without a host. +fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option { + let host = ctx.env.host?; + // Dedupe times (sub-microsecond differences are the same frame). + let mut uniq: Vec = times.iter().map(|t| (t * 1e6).round() as i64).collect(); + uniq.sort_unstable(); + uniq.dedup(); + let bufs: Vec<(i64, Option)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, 0))).collect(); + let first = bufs.iter().find_map(|(_, b)| b.as_ref())?; + let scale = first.scale; + // Union of every frame's extent in layer pixels (offset = where layer (0,0) sits). + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for b in bufs.iter().filter_map(|(_, b)| b.as_ref()) { + let k = scale / b.scale.max(1e-9); + x0 = x0.min(-b.offset[0] * k); + y0 = y0.min(-b.offset[1] * k); + x1 = x1.max((b.img.width as f64 - b.offset[0]) * k); + y1 = y1.max((b.img.height as f64 - b.offset[1]) * k); + } + let (x0, y0) = (x0.floor(), y0.floor()); + let w = ((x1.ceil() - x0) as u32).clamp(1, 16384); + let h = ((y1.ceil() - y0) as u32).clamp(1, 16384); + let grid = Buf { img: Image::new(w, h), offset: [-x0, -y0], scale }; + let place = |b: &Buf| -> Image { + if (b.scale - scale).abs() < 1e-9 && b.img.width == w && b.img.height == h && (b.offset[0] + x0).abs() < 1e-6 && (b.offset[1] + y0).abs() < 1e-6 { + return b.img.clone(); + } + let k = b.scale / scale; + let same_scale = (k - 1.0).abs() < 1e-9; + let dx = b.offset[0] + x0; + let dy = b.offset[1] + y0; + let mut out = Image::new(w, h); + let ow = w as usize; + out.data.par_chunks_mut(ow).enumerate().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = if same_scale && dx.fract() == 0.0 && dy.fract() == 0.0 { + b.img.get(x as i64 + dx as i64, y as i64 + dy as i64) + } else { + let sx = (x as f64 + 0.5) * k + dx - 0.5; + let sy = (y as f64 + 0.5) * k + dy - 0.5; + b.img.sample_bilinear(sx + 0.5, sy + 0.5) + }; + } + }); + out + }; + let placed: Vec<(i64, Image)> = bufs.iter().map(|(k, b)| (*k, b.as_ref().map(place).unwrap_or_else(|| Image::new(w, h)))).collect(); + let imgs = times + .iter() + .map(|t| { + let k = (t * 1e6).round() as i64; + placed.iter().find(|(q, _)| *q == k).map(|(_, i)| i.clone()).unwrap_or_else(|| Image::new(w, h)) + }) + .collect(); + Some(Frames { grid, imgs }) +} + +fn with_img(grid: Buf, img: Image) -> Buf { + Buf { img, ..grid } +} + +/// Weighted average of frames (weights sum to 1 for a plain average). +fn average(frames: &[Image], weights: &[f32]) -> Image { + let w = frames[0].width; + let h = frames[0].height; + let mut out = Image::new(w, h); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let mut acc = [0.0f32; 4]; + for (f, k) in frames.iter().zip(weights) { + let p = f.data[i]; + for c in 0..4 { + acc[c] += p[c] * k; + } + } + *o = acc; + }); + out +} + +fn mean_of(ctx: &EffectCtx, b: Buf, times: &[f64]) -> Buf { + if times.is_empty() { + return b; + } + let Some(fr) = fetch(ctx, times) else { return b }; + let k = 1.0 / times.len() as f32; + let img = average(&fr.imgs, &vec![k; times.len()]); + with_img(fr.grid, img) +} + +// ---------------------------------------------------------------- Echo + +fn echo(ctx: &EffectCtx, b: Buf) -> Buf { + let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize; + let dt = ctx.params.f("echoTime"); + let start = ctx.params.f("startingIntensity").clamp(0.0, 1.0) as f32; + let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; + let op = ctx.params.e("echoOperator"); + let times: Vec = (0..=n).map(|i| ctx.time + i as f64 * dt).collect(); + let Some(fr) = fetch(ctx, ×) else { return b }; + let weights: Vec = (0..=n).map(|i| start * decay.powi(i as i32)).collect(); + let count = (n + 1) as f32; + let w = fr.grid.img.width; + let h = fr.grid.img.height; + let mut out = Image::new(w, h); + let imgs = &fr.imgs; + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let mut acc: Px = match op { + 2 => [f32::MAX; 4], + _ => [0.0; 4], + }; + for (f, &k) in imgs.iter().zip(&weights) { + let p = f.data[i]; + let q = [p[0] * k, p[1] * k, p[2] * k, p[3] * k]; + match op { + // Maximum / Minimum + 1 => (0..4).for_each(|c| acc[c] = acc[c].max(q[c])), + 2 => (0..4).for_each(|c| acc[c] = acc[c].min(q[c])), + // Screen + 3 => (0..4).for_each(|c| acc[c] = acc[c] + q[c] - acc[c] * q[c]), + // Composite In Back: each later echo goes behind what is there. + 4 => { + let ia = 1.0 - acc[3]; + (0..4).for_each(|c| acc[c] += q[c] * ia); + } + // Composite In Front: each later echo goes on top. + 5 => { + let ia = 1.0 - q[3]; + (0..4).for_each(|c| acc[c] = q[c] + acc[c] * ia); + } + // Blend (average) and Add + _ => (0..4).for_each(|c| acc[c] += q[c]), + } + } + if op == 6 { + acc.iter_mut().for_each(|c| *c /= count); + } + if op == 2 && acc[0] == f32::MAX { + acc = [0.0; 4]; + } + acc[3] = acc[3].clamp(0.0, 1.0); + for c in 0..3 { + acc[c] = acc[c].max(0.0); + } + *o = acc; + }); + with_img(fr.grid, out) +} + +// ---------------------------------------------------------------- Posterize Time + +/// The layer time Posterize Time holds at `t` for `rate` frames per second. +pub fn posterized_time(t: f64, rate: f64) -> f64 { + if rate <= 0.0 { + return t; + } + ((t * rate) + 1e-6).floor() / rate +} + +fn posterize_time(ctx: &EffectCtx, b: Buf) -> Buf { + let tt = posterized_time(ctx.time, ctx.params.f("frameRate")); + if ctx.env.host.is_none() { + return b; + } + match fetch(ctx, &[tt]) { + Some(mut fr) => { + let img = fr.imgs.pop().unwrap_or_default(); + with_img(fr.grid, img) + } + None => b, + } +} + +// ---------------------------------------------------------------- Time Difference + +fn time_difference(ctx: &EffectCtx, b: Buf) -> Buf { + let Some(host) = ctx.env.host else { return b }; + let off = ctx.params.f("timeOffset"); + let gain = 1.0 + ctx.params.f("contrast") as f32 / 25.0; + let abs = ctx.params.b("absoluteDifference"); + let amode = ctx.params.e("alphaChannel"); + let target_id = ctx.params.get("targetLayer").and_then(Value::as_layer); + let (cur, target) = match target_id.and_then(|id| host.layer_at(id, ctx.env.comp_time + off, false)) { + Some(other) => { + let Some(mut fr) = fetch(ctx, &[ctx.time]) else { return b }; + let cur = fr.imgs.pop().unwrap_or_default(); + let tgt = fit_layer(ctx, &with_img(fr.grid.clone(), cur.clone()), &other, true); + (with_img(fr.grid, cur), tgt) + } + None => { + let Some(mut fr) = fetch(ctx, &[ctx.time, ctx.time + off]) else { return b }; + let tgt = fr.imgs.pop().unwrap_or_default(); + let cur = fr.imgs.pop().unwrap_or_default(); + (with_img(fr.grid, cur), tgt) + } + }; + let mut out = cur.img.clone(); + out.data.par_iter_mut().zip(target.data.par_iter()).for_each(|(o, t)| { + let (c, ca) = unpremul(*o); + let (d, da) = unpremul(*t); + let mut rgb = [0.0f32; 3]; + for i in 0..3 { + let diff = c[i] - d[i]; + rgb[i] = if abs { (diff.abs() * gain).clamp(0.0, 1.0) } else { (0.5 + diff * 0.5 * gain).clamp(0.0, 1.0) }; + } + let light = (rgb[0].max(rgb[1]).max(rgb[2]) + rgb[0].min(rgb[1]).min(rgb[2])) * 0.5; + let a = match amode { + 1 => da, + 2 => (ca + da) * 0.5, + 3 => ca.max(da), + 4 => 1.0, + 5 => light, + 6 => rgb[0].max(rgb[1]).max(rgb[2]), + 7 => (ca - da).abs() * gain, + 8 => { + let a = ((ca - da).abs() * gain).clamp(0.0, 1.0); + rgb = [1.0; 3]; + a + } + _ => ca, + } + .clamp(0.0, 1.0); + *o = [rgb[0] * a, rgb[1] * a, rgb[2] * a, a]; + }); + Buf { img: out, ..cur } +} + +// ---------------------------------------------------------------- Time Displacement + +fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf { + if ctx.env.host.is_none() { + return b; + } + let max = ctx.params.f("maxDisplacementTime"); + let res = ctx.params.f("timeResolution").clamp(1.0, 999.0); + let stretch = ctx.params.b("stretchMap"); + let Some(mut cur) = fetch(ctx, &[ctx.time]) else { return b }; + let cur_img = cur.imgs.pop().unwrap_or_default(); + let grid = with_img(cur.grid, cur_img); + let map = match ctx.layer_param("displacementMapLayer", true) { + Some(o) => fit_layer(ctx, &grid, &o, stretch), + None => grid.img.clone(), + }; + // Per pixel: luminance 0..1 → offset −max..+max, quantised to the time resolution. Keep at + // most 64 distinct sample times (coarser steps for huge ranges). + let span = 2.0 * max.abs() * res; + let step = if span > 63.0 { 2.0 * max.abs() / 63.0 } else { 1.0 / res }; + let idx: Vec = map + .data + .par_iter() + .map(|p| { + let (c, a) = unpremul(*p); + let l = if a > 0.0 { effectcraft_color::luminance(c[0], c[1], c[2]) } else { 0.5 }; + let off = (l as f64 - 0.5) * 2.0 * max; + (off / step).round() as i32 + }) + .collect(); + let mut keys: Vec = idx.clone(); + keys.sort_unstable(); + keys.dedup(); + if keys == [0] { + return grid; + } + let times: Vec = keys.iter().map(|k| ctx.time + *k as f64 * step).collect(); + let Some(fr) = fetch(ctx, ×) else { return grid }; + // Frames come back on the union grid; map the current grid into it. + let dx = (fr.grid.offset[0] - grid.offset[0]).round() as i64; + let dy = (fr.grid.offset[1] - grid.offset[1]).round() as i64; + let w = grid.img.width as usize; + let mut out = grid.img.clone(); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let k = idx[y * w + x]; + let fi = keys.binary_search(&k).unwrap_or(0); + *o = fr.imgs[fi].get(x as i64 + dx, y as i64 + dy); + } + }); + with_img(grid, out) +} + +// ---------------------------------------------------------------- Timewarp + +/// Source layer time Timewarp shows at layer time `t`. +pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 { + if by_frame { source_frame / fps } else { t * speed / 100.0 } +} + +fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { + if ctx.env.host.is_none() { + return b; + } + let fps = ctx.fps(); + let by_frame = ctx.params.e("adjustTimeBy") == 1; + let speed = ctx.params.f("speed"); + let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); + let method = ctx.params.e("method"); + // Sample times (with weights) for one instant of source time. + let instant = |s: f64| -> Vec<(f64, f32)> { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + let frac = (f - f0) as f32; + if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] } + }; + let mut samples: Vec<(f64, f32)> = Vec::new(); + if ctx.params.b("enableMotionBlur") { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let rate = if by_frame { 1.0 } else { speed / 100.0 }; + let span = angle / 360.0 * rate / fps; + for i in 0..n { + let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }; + samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32))); + } + } else { + samples = instant(src); + } + let times: Vec = samples.iter().map(|s| s.0).collect(); + let weights: Vec = samples.iter().map(|s| s.1).collect(); + let Some(fr) = fetch(ctx, ×) else { return b }; + let img = average(&fr.imgs, &weights); + with_img(fr.grid, img) +} + +// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur + +fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { + let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize; + let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 }; + let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect(); + mean_of(ctx, b, ×) +} + +fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf { + let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64; + let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64; + let fd = 1.0 / ctx.fps(); + let times: Vec = (-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect(); + mean_of(ctx, b, ×) +} + +fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); + mean_of(ctx, b, ×) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + + /// A layer whose every pixel at layer time `t` is grey `t` (clamped), alpha 1, on a 6×4 grid. + struct Clock { + static_layer: bool, + } + impl EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, effects: usize) -> Option { + assert_eq!(effects, 0); + let v = if self.static_layer { 0.4 } else { t as f32 }; + Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + } + + fn run(id: &str, vals: &[(&str, Value)], t: f64, host: &Clock) -> Buf { + let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, ..Default::default() }; + run_fx(id, vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env) + } + + #[test] + fn posterize_time_holds_frames() { + let h = Clock { static_layer: false }; + for (t, held) in [(0.0, 0.0), (0.1, 0.0), (0.24, 0.0), (0.25, 0.25), (0.49, 0.25), (0.5, 0.5)] { + let out = run("ec.time.posterizetime", &[("frameRate", num(4.0))], t, &h); + assert!((out.img.data[0][0] - held).abs() < 1e-5, "t={t}: {}", out.img.data[0][0]); + } + } + + #[test] + fn echo_blends_known_frames_with_decay() { + let h = Clock { static_layer: false }; + let vals = [("echoTime", num(-0.1)), ("numberOfEchoes", num(2.0)), ("startingIntensity", num(1.0)), ("decay", num(0.5))]; + let out = run("ec.time.echo", &vals, 0.8, &h); + // Add: f(0.8) + 0.5 f(0.7) + 0.25 f(0.6) + let want = 0.8 + 0.5 * 0.7 + 0.25 * 0.6; + assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]); + assert_eq!(out.img.data[0][3], 1.0); + // Maximum picks the brightest weighted frame; Blend averages. + let mut v = vals.to_vec(); + v.push(("echoOperator", Value::Enum(1))); + let out = run("ec.time.echo", &v, 0.8, &h); + assert!((out.img.data[0][0] - 0.8).abs() < 1e-5); + v.pop(); + v.push(("echoOperator", Value::Enum(6))); + let out = run("ec.time.echo", &v, 0.8, &h); + assert!((out.img.data[0][0] - want as f32 / 3.0).abs() < 1e-4); + } + + #[test] + fn time_difference_of_static_layer_is_black() { + let h = Clock { static_layer: true }; + let out = run("ec.time.timedifference", &[("timeOffset", num(-0.5))], 1.0, &h); + assert!(out.img.data.iter().all(|p| p[0] == 0.0 && p[1] == 0.0 && p[2] == 0.0 && p[3] == 1.0)); + // A changing layer is not black: |1.0 - 0.5| = 0.5. + let h = Clock { static_layer: false }; + let out = run("ec.time.timedifference", &[("timeOffset", num(-0.5))], 1.0, &h); + assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]); + } + + #[test] + fn timewarp_and_wide_time() { + let h = Clock { static_layer: false }; + // Speed 50 % at t = 1 s shows source 0.5 s (frame-aligned at 10 fps). + let out = run("ec.time.timewarp", &[("method", Value::Enum(0))], 1.0, &h); + assert!((out.img.data[0][0] - 0.5).abs() < 1e-5); + // Frame Mix between 0.5 and 0.6 at source 0.55. + let out = run("ec.time.timewarp", &[("method", Value::Enum(1))], 1.1, &h); + assert!((out.img.data[0][0] - 0.55).abs() < 1e-4, "{}", out.img.data[0][0]); + // Source Frame mode: frame 3 at 10 fps. + let out = run("ec.time.timewarp", &[("method", Value::Enum(0)), ("adjustTimeBy", Value::Enum(1)), ("sourceFrame", num(3.0))], 2.0, &h); + assert!((out.img.data[0][0] - 0.3).abs() < 1e-5); + // Wide Time is symmetric around t: mean of t-0.3..t+0.3 = t. + let out = run("ec.time.ccwidetime", &[], 1.0, &h); + assert!((out.img.data[0][0] - 1.0).abs() < 1e-4); + // Pixel Motion Blur is centred too. + let out = run("ec.time.pixelmotionblur", &[], 1.0, &h); + assert!((out.img.data[0][0] - 1.0).abs() < 1e-4); + // Force Motion Blur looks forward over the shutter. + let out = run("ec.time.ccforcemotionblur", &[], 1.0, &h); + assert!(out.img.data[0][0] > 1.0); + } + + #[test] + fn time_displacement_uses_luma_as_offset() { + // Static map from a bright self frame: luminance 1.0 → +max seconds. + let h = Clock { static_layer: true }; + let out = run("ec.time.timedisplacement", &[], 0.0, &h); + assert!((out.img.data[0][0] - 0.4).abs() < 1e-5); + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 78ff39b5..0f80927b 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -202,6 +202,41 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { + // Time effects see property values at other times. Keyframes are part of the hashed + // structure (and the layer time is folded in), but expressions may read other layers at + // those times, which the key cannot see: such layers are not cached. + if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { + return None; + } + key_with(ctx, layer, scale, draft, false) +} + +/// Cache key for a layer's *input* at the context time: source → masks → its first `effects` +/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here +/// (keyed by item and source time), since Time effects read many neighbouring frames. +pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option { + let base = key_with(ctx, layer, scale, draft, true)?; + let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); + effects.hash(&mut h); + Some(h.finish()) +} + +/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)? +fn reads_other_times(layer: &Layer) -> bool { + layer.switches.effects + && layer.effects().is_some_and(|fx| { + fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time."))) + }) +} + +fn has_expression(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.has_expression(), + Node::Group(sub) => has_expression(sub), + }) +} + +fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } @@ -213,6 +248,13 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} + LayerSource::Footage { item } if footage => { + let it = ctx.project.item(*item)?; + let ItemKind::Footage(f) = &it.kind else { return None }; + hash_debug(&mut h, f); + item.hash(&mut h); + ctx.source_time(layer).0.hash(&mut h); + } _ => return None, } ctx.comp_id.hash(&mut h); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 02f61df6..1299f427 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -43,12 +43,17 @@ struct FxHost<'r, 'a, 'c> { r: &'r Renderer<'a>, ctx: &'c EvalCtx<'a>, layer: &'c Layer, + /// Index of the effect being rendered (bounds `self_at` to effects before it). + index: std::sync::atomic::AtomicUsize, } +/// Nesting limit for effects that render layers (other layers, other times). +const MAX_FX_DEPTH: usize = 8; + impl EffectHost for FxHost<'_, '_, '_> { fn layer(&self, id: u64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; - if other.id == self.layer.id || self.r.depth > 8 { + if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { return None; } let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; @@ -69,6 +74,29 @@ impl EffectHost for FxHost<'_, '_, '_> { let st = other.layer_time(Tick::from_seconds_f64(start)); self.r.footage.audio(*item, f, st, frames, rate) } + + fn self_at(&self, layer_time: f64, effects: usize) -> Option { + if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment { + return None; + } + let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed)); + let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time)); + let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone()) + } + + 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 { + return None; + } + let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time)); + let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let buf = if masks_and_effects { (*sub.layer_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() }; + let size = source_size(self.r.project, other); + let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + Some(LayerPixels { buf, size }) + } } /// No footage available (renders footage layers as transparent). @@ -186,10 +214,19 @@ impl<'a> Renderer<'a> { } fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf { - self.apply_effects_timed(ctx, layer, buf, adjustment, None) + self.apply_effects_timed(ctx, layer, buf, adjustment, usize::MAX, None) } - fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf { + /// Run the first `limit` effects of the layer's stack (by position; disabled ones count). + fn apply_effects_timed( + &self, + ctx: &EvalCtx, + layer: &Layer, + mut buf: Buf, + adjustment: bool, + limit: usize, + mut timing: Option<&mut Vec<(String, f64)>>, + ) -> Buf { if !layer.switches.effects { return buf; } @@ -198,15 +235,24 @@ impl<'a> Renderer<'a> { let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let mask_shapes = masks::shapes(ctx, layer); - let host = FxHost { r: self, ctx, layer }; - let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() }; - for g in fx.groups() { + 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 }; + for (i, g) in fx.groups().enumerate() { + if i >= limit { + break; + } if !g.enabled { continue; } let GroupKind::Effect { effect } = &g.kind else { continue }; let Some(spec) = effectcraft_effects::find(effect) else { continue }; + if effectcraft_effects::audio_fx::is_audio_effect(spec.id) { + continue; + } let params = self.effect_params(ctx, layer, g); + host.index.store(i, std::sync::atomic::Ordering::Relaxed); + let env = EffectEnv { effect_index: i, ..env }; let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = std::time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); @@ -280,7 +326,29 @@ impl<'a> Renderer<'a> { } let mut buf = self.source(ctx, layer)?; masks::apply(ctx, layer, &mut buf); - let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects))); + let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, buf.clone()); + } + Some(buf) + } + + /// A layer's input at the context time: source → masks → its first `effects` effects + /// (what Time effects read at neighbouring times). Served from / stored in the layer cache + /// under its own key, so scrubbing reuses frames rendered for earlier output frames. + pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { + let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.opts.scale, self.opts.draft, effects)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(b); + } + let mut buf = self.source(ctx, layer)?; + masks::apply(ctx, layer, &mut buf); + if effects > 0 { + buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None); + } + let buf = Arc::new(buf); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, buf.clone()); } @@ -453,3 +521,5 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath { effectcraft_path::to_kurbo(sp) } +#[cfg(test)] +mod tests_time; diff --git a/crates/render/src/tests_time.rs b/crates/render/src/tests_time.rs new file mode 100644 index 00000000..b2833fb0 --- /dev/null +++ b/crates/render/src/tests_time.rs @@ -0,0 +1,105 @@ +//! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the +//! layer cache staying correct while scrubbing. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +fn render(p: &Project, cid: ItemId, t: f64, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(t)) +} + +/// A white 200×100 solid whose full-frame mask fades in (opacity 0 → 100 over 0..1 s), with +/// effect `fx` (and parameter overrides) applied. +fn scene(fx: Option<(&str, &[(&str, Value)])>) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([100.0, 50.0], 400.0, 200.0), MaskMode::Add, [255, 255, 0]); + if let Some(o) = m.prop_mut("opacity") { + o.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))]; + } + l.props.sub_mut("masks").unwrap().children.push(m.into()); + if let Some((id, vals)) = fx { + let spec = effectcraft_effects::find(id).unwrap(); + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v.clone(); + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.next_id = next; + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn alpha(img: &crate::Image) -> f32 { + img.get(100, 50)[3] +} + +#[test] +fn posterize_time_holds_frames() { + let (p, cid) = scene(Some(("ec.time.posterizetime", &[("frameRate", Value::Scalar(4.0))]))); + for (t, want) in [(0.3, 0.25), (0.45, 0.25), (0.5, 0.5), (0.74, 0.5), (0.8, 0.75)] { + let a = alpha(&render(&p, cid, t, None)); + assert!((a - want).abs() < 0.01, "t={t}: {a}"); + } + let (plain, cid) = scene(None); + assert!((alpha(&render(&plain, cid, 0.3, None)) - 0.3).abs() < 0.01); +} + +#[test] +fn echo_blends_neighbouring_frames() { + let vals = [("echoTime", Value::Scalar(-0.1)), ("numberOfEchoes", Value::Scalar(2.0)), ("decay", Value::Scalar(0.5))]; + let (p, cid) = scene(Some(("ec.time.echo", &vals))); + let a = alpha(&render(&p, cid, 0.3, None)); + let want = 0.3 + 0.5 * 0.2 + 0.25 * 0.1; + assert!((a - want).abs() < 0.01, "{a} vs {want}"); +} + +#[test] +fn time_difference_of_static_layer_is_black() { + let mut p = Project::default(); + let comp = Comp::new(64, 32, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.8, 0.4, 0.2], width: 64, height: 32, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 32), None); + let spec = effectcraft_effects::find("ec.time.timedifference").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 32.0]); + g.prop_mut("timeOffset").unwrap().value = Value::Scalar(-0.5); + p.next_id = next; + l.props.sub_mut("effects").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render(&p, cid, 1.0, None); + assert!(img.data.iter().all(|px| px[0] == 0.0 && px[1] == 0.0 && px[2] == 0.0 && px[3] == 1.0), "{:?}", img.get(10, 10)); +} + +/// Scrubbing back and forth with the layer cache gives exactly the uncached frames (Echo's +/// output depends on neighbouring frames, so its key must include the layer time), and an edit +/// to the animation is picked up. +#[test] +fn cache_correct_while_scrubbing() { + let vals = [("echoTime", Value::Scalar(-0.2)), ("numberOfEchoes", Value::Scalar(3.0)), ("decay", Value::Scalar(0.6)), ("echoOperator", Value::Enum(6))]; + let (mut p, cid) = scene(Some(("ec.time.echo", &vals))); + let cache = crate::LayerCache::default(); + for t in [0.3, 0.5, 0.3, 1.2, 0.8, 0.5, 0.3, 1.5] { + let a = render(&p, cid, t, Some(&cache)); + let b = render(&p, cid, t, None); + assert_eq!(a.data, b.data, "t={t}"); + } + let st = cache.stats(); + assert!(st.hits > 0, "{st:?}"); + // Re-key the fade (0 → 100 over 0..0.5 s): cached frames must not be served. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("masks/#1/opacity").unwrap().keys[1].time = Tick::from_seconds_f64(0.5); + for t in [0.3, 0.5, 0.8] { + assert_eq!(render(&p, cid, t, Some(&cache)).data, render(&p, cid, t, None).data, "edited t={t}"); + } +}