diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 27d2264e5..cf7624d92 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -451,11 +451,76 @@ fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf { // ---------------------------------------------------------------- Timewarp -/// Source layer time Timewarp shows at layer time `t`. +/// Source layer time Timewarp shows at layer time `t` when Speed is the same at every time: +/// Source Frame maps directly, Speed (a rate in %) gives `t × speed / 100`. An animated Speed +/// is integrated instead (see [`timewarp_integrate_speed`]). 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 } } +/// Speed samples per composition frame when integrating an animated Speed. +const TW_SPEED_SAMPLES_PER_FRAME: f64 = 2.0; +/// Most Speed samples one integration takes (longer spans widen the step instead). +const TW_SPEED_MAX_SAMPLES: f64 = 50_000.0; + +/// Source layer time of Adjust Time By = Speed at layer time `t` with an animated Speed: Speed +/// is a rate (100 % = real time), so the source time is the integral of `speed / 100` over layer +/// time from 0 to `t` (toward 0 with the sign for negative `t`). `speed_at(lt)` is Speed at layer +/// time `lt`; a missing or non-finite sample counts as `now` (the current Speed), or 0 when that +/// is not finite either. Midpoint rule on a fixed grid from 0, [`TW_SPEED_SAMPLES_PER_FRAME`] +/// samples per frame at `fps`, at most [`TW_SPEED_MAX_SAMPLES`] samples. A non-finite result falls back to +/// `t × now / 100`, then to 0. +pub fn timewarp_integrate_speed(t: f64, fps: f64, now: f64, speed_at: impl Fn(f64) -> Option) -> f64 { + let flat = t * now / 100.0; + let fallback = |v: f64| { + if v.is_finite() { + v + } else if flat.is_finite() { + flat + } else { + 0.0 + } + }; + if !t.is_finite() || t == 0.0 { + return fallback(flat); + } + let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 }; + let span = t.abs(); + // Cells of a fixed step anchored at 0 (whole cells plus a partial last one), so successive + // frames share their samples and a Hold key's jump lands the same way on every frame; only a + // span past the sample budget widens the step. + let mut h = 1.0 / (fps * TW_SPEED_SAMPLES_PER_FRAME); + if !(span / h).is_finite() || span / h >= TW_SPEED_MAX_SAMPLES { + h = span / TW_SPEED_MAX_SAMPLES; + } + if !h.is_finite() || h <= 0.0 { + return fallback(flat); + } + let whole = (span / h).floor().clamp(0.0, TW_SPEED_MAX_SAMPLES); + let rest = (span - whole * h).max(0.0); + let sign = t.signum(); + let held = if now.is_finite() { now } else { 0.0 }; + let sample = |lt: f64| speed_at(sign * lt).filter(|v| v.is_finite()).unwrap_or(held); + // `whole` is a whole number in 0..=TW_SPEED_MAX_SAMPLES, so the count is exact. + let full: f64 = (0..whole as u64).map(|i| sample((i as f64 + 0.5) * h)).sum::() * h; + let part = if rest > 0.0 { sample(whole * h + rest * 0.5) * rest } else { 0.0 }; + fallback(sign * (full + part) / 100.0) +} + +/// Source layer time Timewarp shows at the frame: [`timewarp_source_time`], except that an +/// animated Speed (keyframes or an expression, read through the effect host) is integrated +/// from layer time 0 ([`timewarp_integrate_speed`]), so a speed ramp plays on from where the +/// footage is instead of jumping to another segment. +fn timewarp_source(ctx: &EffectCtx, by_frame: bool, speed: f64, fps: f64) -> f64 { + let direct = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); + if by_frame { + return direct; + } + let idx = ctx.env.effect_index; + let Some(host) = ctx.env.host.filter(|h| !h.param_is_static(idx, "speed")) else { return direct }; + timewarp_integrate_speed(ctx.time, fps, speed, |lt| host.param_at(idx, "speed", lt).map(|v| v.as_f64())) +} + /// Timewarp's Pixel Motion settings (the Tuning group and Source Crops). #[derive(Clone, Copy, Debug)] pub struct PixelMotion { @@ -747,7 +812,7 @@ fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec, f64) { 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 src = timewarp_source(ctx, by_frame, speed, fps); let mut instants: Vec<(f64, f32)> = vec![]; if ctx.params.b("motionBlur/enableMotionBlur") { let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 }; @@ -879,7 +944,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option { /// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning /// group. A Matte Layer splits foreground and background so each moves on its own vectors; a /// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter -/// Samples instants across the shutter. +/// Samples instants across the shutter. Adjust Time By = Speed treats Speed as a rate +/// integrated from layer time 0 (keyframing it ramps the playback speed). fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { match timewarp_plan(ctx) { Some(plan) => plan.render_cpu(), @@ -1188,6 +1254,88 @@ mod tests { assert!(out.img.data[0][0] > 1.0); } + /// [`Clock`] whose Timewarp (effect 2) Speed is animated: `speed(lt)` at layer time `lt`. + struct Ramp { + speed: fn(f64) -> f64, + } + impl EffectHost for Ramp { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + let v = t as f32; + Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + /// Timewarp's (effect 2) Speed follows `speed`; every other parameter is unknown. + fn param_at(&self, effect_index: usize, id: &str, lt: f64) -> Option { + (effect_index == 2 && id == "speed").then(|| num((self.speed)(lt))) + } + /// Only Timewarp's (effect 2) Speed is animated. + fn param_is_static(&self, effect_index: usize, id: &str) -> bool { + !(effect_index == 2 && id == "speed") + } + } + + fn run_ramp(vals: &[(&str, Value)], t: f64, host: &Ramp) -> Buf { + let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, effect_index: 2, ..Default::default() }; + run_fx("ec.time.timewarp", vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env) + } + + /// #2803: an animated Speed is a rate integrated from layer time 0, so a speed ramp plays on + /// from where the footage is instead of jumping to another segment. + #[test] + fn timewarp_integrates_animated_speed() { + // Speed 100 % for the first second, then 50 %: at t = 2 s the source is 1 + 0.5 = 1.5 s + // (`t × speed` would jump back to 1.0 s). + let h = Ramp { speed: |lt| if lt < 1.0 { 100.0 } else { 50.0 } }; + let vals = [("method", Value::Enum(0)), ("speed", num(50.0))]; + let out = run_ramp(&vals, 2.0, &h); + assert!((out.img.data[0][0] - 1.5).abs() < 1e-4, "{}", out.img.data[0][0]); + // Continuous across the change: just past 1 s the source is just past 1 s. + let out = run_ramp(&vals, 1.2, &h); + assert!((out.img.data[0][0] - 1.1).abs() < 1e-4, "{}", out.img.data[0][0]); + // Negative layer time integrates toward 0. + let src = timewarp_integrate_speed(-2.0, 10.0, 50.0, |lt| Some(if lt > -1.0 { 100.0 } else { 50.0 })); + assert!((src + 1.5).abs() < 1e-9, "{src}"); + // A Hold key between samples (1.03 s) lands the same way on every frame: past it, each + // 0.1 s frame advances the source by exactly 50 % of a frame. + let hold = |lt: f64| Some(if lt < 1.03 { 100.0 } else { 50.0 }); + let at = |t: f64| timewarp_integrate_speed(t, 10.0, 50.0, hold); + for k in 12..40 { + let (a, b) = (at((k - 1) as f64 / 10.0), at(k as f64 / 10.0)); + assert!((b - a - 0.05).abs() < 1e-9, "k={k}: {}", b - a); + } + // A static Speed keeps `t × speed / 100` exactly. + assert_eq!(timewarp_source_time(2.0, false, 37.0, 0.0, 10.0), 2.0 * 37.0 / 100.0); + // Source Frame mode ignores Speed. + let out = run_ramp(&[("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); + } + + /// Never crash: non-finite or huge Speeds, times and frame rates give finite source times + /// and pixels. + #[test] + fn timewarp_animated_speed_survives_bad_values() { + // Non-finite samples count as the current Speed (or 0); the result is always finite. + for t in [0.0, 1.0, -3.0, 1e9, f64::MAX, f64::INFINITY, f64::NAN] { + for now in [50.0, f64::NAN, f64::INFINITY, 1e308] { + let src = timewarp_integrate_speed(t, 10.0, now, |lt| Some(if lt < 0.5 { f64::NAN } else { 1e308 })); + assert!(src.is_finite(), "t={t} now={now}: {src}"); + let src = timewarp_integrate_speed(t, f64::NAN, now, |_| None); + assert!(src.is_finite(), "t={t} now={now}: {src}"); + } + } + let h = Ramp { speed: |lt| if lt < 1.0 { f64::NAN } else { f64::INFINITY } }; + let out = run_ramp(&[("method", Value::Enum(2)), ("speed", num(50.0)), ("motionBlur/enableMotionBlur", Value::Bool(true))], 2.0, &h); + assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))); + let h = Ramp { speed: |_| 1e308 }; + let out = run_ramp(&[("method", Value::Enum(1)), ("speed", num(1e308))], 2.0, &h); + assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))); + } + #[test] fn time_displacement_uses_luma_as_offset() { // Static map from a bright self frame: luminance 1.0 → +max seconds.