M9.5: time effects (Echo, Posterize Time, Time Difference, Time Displacement, Timewarp, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur)
EffectHost gains self_at (the layer's source + masks + first N effects at another layer time) and layer_at (another layer at another comp time); EffectEnv carries the effect's stack index. The renderer serves these via Renderer::layer_input, cached in the layer cache under input keys (footage included, keyed by source time) with a depth guard against recursion. Time effects are TIME_DEPENDENT so processed-layer keys fold in layer time; layers with time effects and expressions are not cached. Also registers the Effect > Audio specs (DSP in effects::audio_fx; the mixdown integration follows in M11.2). Audio effects are skipped by the video effect pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d73f199f38
commit
1c0da40173
|
|
@ -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<crate::ParamSpec>) -> 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<EffectSpec> {
|
||||
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<f64>, 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<f64>, 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<f64> = (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<f32> {
|
||||
(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<f32> = 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<f32>>;
|
||||
/// 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<Buf> {
|
||||
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<LayerPixels> {
|
||||
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`].
|
||||
|
|
|
|||
|
|
@ -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<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Time", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
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<Image>,
|
||||
}
|
||||
|
||||
/// Fetch the layer (source + masks) at layer times `times`. `None` without a host.
|
||||
fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option<Frames> {
|
||||
let host = ctx.env.host?;
|
||||
// Dedupe times (sub-microsecond differences are the same frame).
|
||||
let mut uniq: Vec<i64> = times.iter().map(|t| (t * 1e6).round() as i64).collect();
|
||||
uniq.sort_unstable();
|
||||
uniq.dedup();
|
||||
let bufs: Vec<(i64, Option<Buf>)> = 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<f64> = (0..=n).map(|i| ctx.time + i as f64 * dt).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let weights: Vec<f32> = (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<i32> = 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<i32> = idx.clone();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
if keys == [0] {
|
||||
return grid;
|
||||
}
|
||||
let times: Vec<f64> = 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<f64> = samples.iter().map(|s| s.0).collect();
|
||||
let weights: Vec<f32> = 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<f64> = (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<f64> = (-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<f64> = (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<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, effects: usize) -> Option<Buf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u64> {
|
||||
// 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<u64> {
|
||||
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<u64> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<LayerPixels> {
|
||||
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<Buf> {
|
||||
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<LayerPixels> {
|
||||
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<Arc<Buf>> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue