diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 4f72e79fa..d58301185 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -630,6 +630,15 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { }) } +/// A frame-blended layer's pixels also depend on how far its source runs during the comp frame: +/// a fast-conformed clip mixes every source frame the comp frame spans (#3180), so a time +/// stretch or remap change can alter the pixels at an unchanged source time. +fn hash_blend_spans(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer) { + if crate::frames::frame_blend_mode(ctx, layer) != effectcraft_project::FrameBlend::Off { + crate::frames::blend_spans(ctx, layer).hash(h); + } +} + /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. /// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values @@ -801,6 +810,7 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo } // Frame blending mixes neighbouring source frames. ctx.comp.enable_frame_blending.hash(&mut h); + hash_blend_spans(&mut h, ctx, layer); } _ => return None, } diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 6f14ddcde..9b9b5e79a 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -124,6 +124,8 @@ impl Dependencies<'_> { Some(()) } + /// Folds what the layer contributes at this time into the dependency cache key, including its source, effects and frame blend state. + /// Returns `None` when the layer cannot be keyed: past the depth limit, in a reference cycle, or with expressions that read other times. fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { @@ -167,7 +169,10 @@ impl Dependencies<'_> { hash_debug(self.h, &source); if let Some(source) = source { match &source.kind { - ItemKind::Footage(_) => ctx.source_time(layer).0.hash(self.h), + ItemKind::Footage(_) => { + ctx.source_time(layer).0.hash(self.h); + super::hash_blend_spans(self.h, ctx, layer); + } ItemKind::Comp(comp) => { let time = ctx.source_time(layer); time.0.hash(self.h); diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 20c655694..7d072438c 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -89,6 +89,84 @@ pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) { (num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64) } +/// The fewest source frames one comp frame must run through before frame blending averages all +/// of them ([`mix_window`]); below this the two neighbouring frames blend as they always have. +/// Conservative on purpose: common footage (60p in a 30, 25 or 24 fps comp, 50p in 25) keeps +/// its familiar look, while fast-conformed footage (600 fps in a 25 fps comp, #3180) gets the +/// mix. Pending a comparison with After Effects, whose exact threshold is not documented. +pub const MIN_MIX_FRAMES: f64 = 4.0; + +/// The most source frames one comp frame mixes; a wider span is sampled at this many evenly +/// spaced frames (After Effects is reported to blend about a dozen; this also bounds the +/// decoding per comp frame). Pending a comparison with After Effects. +pub const MAX_MIX_FRAMES: usize = 16; + +/// A span of more source frames than this in one comp frame is a jump (a time remap cut), not +/// motion to blur. +pub const MAX_MIX_SPAN: f64 = 1000.0; + +/// How finely [`blend_spans`] probes the source's speed: at 1/16 of a comp frame. +const SPEED_PROBE: i64 = 16; + +/// How far a frame-blended layer's source runs during one comp frame from the context time +/// (Interpret Footage's conformed rate, time stretch and time remap all show up in it), as +/// `(local, whole)`: the local speed just after the comp frame's time scaled to a whole frame, +/// and the actual difference to the next comp frame. They disagree across a time remap jump +/// (a hold keyframe followed by a cut, a `loopOut` wrapping back), which then does not mix. +/// Measured on the shown source time ([`EvalCtx::nested_time`]: a precomp proxy that preserves +/// its frame rate is floored to its frames). +pub(crate) fn blend_spans(ctx: &EvalCtx, layer: &Layer) -> (Tick, Tick) { + let frame = ctx.comp.frame_duration(); + let t0 = ctx.nested_time(layer); + let probe = Tick(frame.0 / SPEED_PROBE); + let local = ctx.at(ctx.time + probe).nested_time(layer) - t0; + let whole = ctx.at(ctx.time + frame).nested_time(layer) - t0; + (Tick(local.0.saturating_mul(SPEED_PROBE)), whole) +} + +/// The source frames a frame-blended comp frame averages when the layer runs through many +/// source frames per comp frame (footage interpreted at a much higher frame rate than the comp, +/// a sped-up time stretch or time remap), so a comp frame skips whole source frames: the frames +/// at `rate` that one comp frame's worth of source from `t` overlaps, each weighted by the share +/// of a frame it covers. `spans` is [`blend_spans`]' `(local, whole)`; playing forwards the span +/// is `[t, t + span)`, backwards it ends with the frame at `t` (the unblended frame). `None` — +/// the two neighbouring frames then blend ([`frame_position`], [`blend_frames`]) — when the span +/// is shorter than [`MIN_MIX_FRAMES`], longer than [`MAX_MIX_SPAN`], or the local speed and the +/// whole frame's difference disagree by more than a source frame (a time remap jump). +pub fn mix_window(t: Tick, spans: (Tick, Tick), rate: FrameRate) -> Option> { + if rate.num <= 0 || rate.den <= 0 { + return None; + } + let frames_of = |s: Tick| s.0 as f64 * rate.num as f64 / (TICKS_PER_SECOND as f64 * rate.den as f64); + let width = frames_of(spans.0); + let whole = frames_of(spans.1); + if !width.is_finite() || !whole.is_finite() || (width - whole).abs() > 1.0 + 1e-9 { + return None; + } + if width.abs() < MIN_MIX_FRAMES - 1e-9 || width.abs() > MAX_MIX_SPAN { + return None; + } + let (i, w) = frame_position(t, rate); + let p = i as f64 + w; + let lo = if width < 0.0 { p + 1.0 + width } else { p }; + let width = width.abs(); + let hi = lo + width; + let first = lo.floor(); + let n = hi.ceil() - first; + if n <= MAX_MIX_FRAMES as f64 { + let frames = (0..n as usize) + .filter_map(|k| { + let j = first + k as f64; + let cover = (j + 1.0).min(hi) - j.max(lo); + (cover > 1e-9).then_some((j as i64, cover)) + }) + .collect(); + return Some(frames); + } + let m = MAX_MIX_FRAMES as f64; + Some((0..MAX_MIX_FRAMES).map(|k| ((lo + width * (k as f64 + 0.5) / m).floor() as i64, 1.0)).collect()) +} + /// Blend two neighbouring frames `w` of the way from `a` to `b`: a cross-fade (Frame Mix) or a /// motion-compensated interpolation (Pixel Motion). pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f47443b18..1a3822f48 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -50,7 +50,7 @@ pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit}; pub use frames::{ adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, - render_frame, roi_inside, + mix_window, render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index c0f4c8dd9..042dc198c 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -93,6 +93,26 @@ pub struct Collapsed { pub(crate) media_overrides: Vec, } +/// Cache key of a frame-blended footage frame: `seed` (which frames, how they mix, the footage +/// file) and the Interpret Footage settings that change the decoded pixels. +fn blended_frame_key(f: &Footage, seed: impl std::hash::Hash) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + seed.hash(&mut h); + // Every file of a sequence: the key must tell two projects apart whose items share an id + // and a first frame (on disk, no project-wide salt does, #3184). + f.sequence.hash(&mut h); + (f.alpha as u8).hash(&mut h); + if f.alpha == effectcraft_project::AlphaMode::Premultiplied { + f.premul_color.map(f32::to_bits).hash(&mut h); + } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } + h.finish() +} + /// The property and media overrides of a collapsed precomp layer. `None` when its Essential /// Properties group or the source comp's definitions are missing (the flattened path then /// preserves the old behaviour). @@ -1223,26 +1243,16 @@ impl<'a> Renderer<'a> { let mode = frame_blend_mode(ctx, layer); let float = self.pipe.float_curve(f); if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { + // Many source frames per comp frame (footage interpreted much faster than the comp, + // #3180): the frames the comp frame spans are averaged. + if let Some(img) = self.mixed_span_frame(ctx, layer, item, f, t, size) { + return Some(img); + } let (i, w) = frame_position(t, f.frame_rate); if w > 1e-6 { // Blended frames are cached (Pixel Motion is expensive). - let key = self.cache.map(|_| { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); - // Every file of a sequence: the key must tell two projects apart whose items - // share an id and a first frame (on disk, no project-wide salt does, #3184). - f.sequence.hash(&mut h); - (f.alpha as u8).hash(&mut h); - if f.alpha == effectcraft_project::AlphaMode::Premultiplied { - f.premul_color.map(f32::to_bits).hash(&mut h); - } - if f.cineon_applies() { - let c = &f.cineon; - [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); - } - h.finish() - }); + let seed = (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float); + let key = self.cache.map(|_| blended_frame_key(f, seed)); if let Some((b, _)) = self.cached(key) { return Some(Arc::new(b.img.clone())); } @@ -1261,6 +1271,58 @@ impl<'a> Renderer<'a> { self.read(|| self.footage.frame_with(item, f, t, size, float)).map(|img| cineon_frame(img, f)) } + /// A frame-blended footage frame at source time `t` when one comp frame runs through many + /// source frames: the average of the source frames the comp frame spans, each weighted by + /// the share of it the span covers ([`super::frames::mix_window`]). Both Frame Mix and + /// Pixel Motion average real frames here (there is no in-between frame to synthesise). + /// `size`: the frames may come resampled to it ([`FootageSource::frame_at_size`]). `None` + /// when the span is too short (or a jump) to mix, or no frame decodes. + fn mixed_span_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + use rayon::prelude::*; + let float = self.pipe.float_curve(f); + let spans = super::frames::blend_spans(ctx, layer); + let frames = super::frames::mix_window(t, spans, f.frame_rate)?; + let seed = (0x00f8_a3e6_u64, item, t.0, spans, size, f.frame_rate.num, f.frame_rate.den, &f.path, float); + let key = self.cache.map(|_| blended_frame_key(f, seed)); + if let Some((b, _)) = self.cached(key) { + return Some(Arc::new(b.img.clone())); + } + // A weighted sum in one buffer, divided by the total weight at the end. A frame that does + // not decode, or has another size than the first (a sequence of mixed sizes), is left + // out and the mix renormalises over the rest. Log scans are converted before they mix. + let ((acc, total), files) = self.recorded(key, |r| { + let mut acc: Option = None; + let mut total = 0.0; + for (j, w) in frames { + let Some(img) = r.read(|| r.footage.frame_with(item, f, f.frame_rate.tick_of(j), size, float)).map(|a| cineon_frame(a, f)) else { continue }; + let sum = acc.get_or_insert_with(|| Image::new(img.width, img.height)); + if (sum.width, sum.height) != (img.width, img.height) { + continue; + } + let wf = w as f32; + sum.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(s, p)| { + for (a, b) in s.iter_mut().zip(p) { + *a += b * wf; + } + }); + total += w; + } + (acc, total) + }); + let mut img = acc?; + if total <= 1e-9 { + return None; + } + let norm = (1.0 / total) as f32; + img.data.par_iter_mut().for_each(|s| { + for c in s.iter_mut() { + *c *= norm; + } + }); + let _kept = self.keep(key, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }), &files); + Some(Arc::new(img)) + } + /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { let (mut buf, quantized) = match self.content_times(ctx, layer) { diff --git a/crates/render/src/tests/frame_blend.rs b/crates/render/src/tests/frame_blend.rs index ac3555af1..ecee73b6d 100644 --- a/crates/render/src/tests/frame_blend.rs +++ b/crates/render/src/tests/frame_blend.rs @@ -5,12 +5,13 @@ use std::sync::Arc; use rayon::prelude::*; use effectcraft_color::Label; -use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_keyframe::{Interp, Keyframe, Value}; use effectcraft_project::build; use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; -use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position}; +use crate::frames::MAX_MIX_FRAMES; +use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position, mix_window}; #[test] fn frame_mix_weights_for_24_to_30_conform() { @@ -57,7 +58,13 @@ impl FootageSource for Frames { } } +/// [`setup_rated`] with 24 fps footage and no recorded native rate. fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { + setup_rated(mode, size, FrameRate::FPS_24, None) +} + +/// [`setup`] with footage at `rate` (its Interpret Footage frame rate) recorded at `native`. +fn setup_rated(mode: FrameBlend, size: (u32, u32), rate: FrameRate, native: Option) -> (Project, ItemId) { let mut p = Project::default(); p.settings.bit_depth = BitDepth::Bpc32; let comp = Comp::new(size.0, size.1, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); @@ -68,8 +75,8 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { width: size.0, height: size.1, pixel_aspect: 1.0, - frame_rate: FrameRate::FPS_24, - native_rate: None, + frame_rate: rate, + native_rate: native, duration: Tick::from_seconds_f64(2.0), has_video: true, has_audio: false, @@ -168,6 +175,172 @@ fn time_stretched_precomp_frame_mixes() { assert!(img.get(12, 10)[3] < 1e-3 && (img.get(17, 10)[3] - 1.0).abs() < 1e-3 && (img.get(22, 10)[3] - 1.0).abs() < 1e-3 && img.get(27, 10)[3] < 1e-3); } +/// Fast-conformed footage (30 fps interpreted as 300 fps) averages every source frame a comp frame spans, for Frame Mix and Pixel Motion. +#[test] +fn frame_mix_averages_every_frame_of_fast_conformed_footage() { + // #3180: 30 fps footage interpreted as 300 fps in a 30 fps comp. Every comp frame lands + // exactly on a source frame, but it spans ten of them: frame blending averages all ten + // (greys 0.0 … 0.9 → 0.45), not just the frame it starts on (0.0). + let fast = FrameRate::new(300, 1); + let (p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + let v = frame(&p, cid, 0, false).get(4, 4)[0]; + assert!((v - 0.45).abs() < 1e-5, "{v}"); + // Pixel Motion has no in-between frame to make either: it averages the same frames. + let (p, cid) = setup_rated(FrameBlend::PixelMotion, (16, 8), fast, Some(FrameRate::FPS_30)); + let v = frame(&p, cid, 0, false).get(4, 4)[0]; + assert!((v - 0.45).abs() < 1e-5, "{v}"); + // Off (layer switch or comp switch): the one frame at the comp frame's time. + let (p, cid) = setup_rated(FrameBlend::Off, (16, 8), fast, Some(FrameRate::FPS_30)); + assert!(frame(&p, cid, 0, false).get(4, 4)[0].abs() < 1e-6); + let (mut p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + assert!(frame(&p, cid, 0, false).get(4, 4)[0].abs() < 1e-6); +} + +/// Footage at the comp's rate, or only mildly faster, renders as before. +#[test] +fn frame_mix_of_common_footage_is_unchanged() { + // Footage at the comp's rate: each comp frame is exactly one source frame, blended or not. + let (p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), FrameRate::FPS_30, None); + for k in 0..4 { + let v = frame(&p, cid, k, false).get(4, 4)[0]; + assert!((v - k as f32 / 10.0).abs() < 1e-6, "comp frame {k}: {v}"); + } + // A mild speed-up (36 fps footage in a 30 fps comp) keeps the two-frame cross-fade: comp + // frame 1 → source frame 1.2, 0.8·frame 1 + 0.2·frame 2 = 0.12. + let (p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), FrameRate::new(36, 1), None); + let v = frame(&p, cid, 1, false).get(4, 4)[0]; + assert!((v - 0.12).abs() < 1e-6, "{v}"); + // 60 and 90 fps in a 30 fps comp (two and three source frames per comp frame) stay crisp: + // comp frame 1 is source frame 2 or 3, as before. + for (rate, want) in [(FrameRate::FPS_60, 0.2), (FrameRate::new(90, 1), 0.3)] { + let (p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), rate, None); + let v = frame(&p, cid, 1, false).get(4, 4)[0]; + assert!((v - want).abs() < 1e-6, "{rate:?}: {v}"); + } +} + +/// Adds Time Remap with `keys` (layer time → source seconds) to the comp's footage layer. +fn remap(p: &mut Project, cid: ItemId, keys: Vec) { + let mut next = p.next_id; + let mut tr = build::Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(0.0)); + p.next_id = next; + tr.keys = keys; + p.comp_mut(cid).unwrap().layers[0].props.children.push(effectcraft_project::Node::Prop(tr)); +} + +/// Time remap holds and cuts do not smear: a still frame stays a single source frame. +#[test] +fn time_remap_jumps_stay_crisp() { + let fast = FrameRate::new(300, 1); + let s = |frames: f64| Value::Scalar(frames / 300.0); + let at = |k: i64| FrameRate::FPS_30.tick_of(k); + // Hold on source frame 0, then cut to source frame 10 at comp frame 1: comp frame 0 is a + // still frame, not a smear of the ten frames the cut skips. + let (mut p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + remap(&mut p, cid, vec![Keyframe::new(at(0), s(0.0)).hold(), Keyframe::new(at(1), s(10.0)).hold()]); + let v = frame(&p, cid, 0, false).get(4, 4)[0]; + assert!(v.abs() < 1e-6, "hold then cut: {v}"); + // A loop wrapping back (like `loopOut("cycle")`): from source frame 10 at normal speed, + // back to frame 0 by the next comp frame. Comp frame 0 shows frame 10 (grey 1.0), not + // frames 1 … 10 run backwards. + let (mut p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + let mut top = Keyframe::new(FrameRate::FPS_60.tick_of(1), s(15.0)); + top.out_interp = Interp::Hold; + remap(&mut p, cid, vec![Keyframe::new(at(0), s(10.0)), top, Keyframe::new(at(1), s(0.0))]); + let v = frame(&p, cid, 0, false).get(4, 4)[0]; + assert!((v - 1.0).abs() < 1e-6, "loop wrap: {v}"); + // A remap running at normal speed through fast-conformed footage still mixes its frames. + let (mut p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + remap(&mut p, cid, vec![Keyframe::new(at(0), s(0.0)), Keyframe::new(at(30), s(300.0))]); + let v = frame(&p, cid, 0, false).get(4, 4)[0]; + assert!((v - 0.45).abs() < 1e-5, "steady remap: {v}"); +} + +/// A frame-blended dependency changes its owner's cache key when its source goes from holding to running at speed. +#[test] +fn a_frame_blended_dependency_keys_how_far_its_source_runs() { + // A footage layer selected by another layer's effect: at comp frames 0 and 1 its source time + // is the same (0), but at frame 0 it holds and at frame 1 it runs at speed, which mixes ten + // source frames. The owner's cache key must tell the two apart. + let fast = FrameRate::new(300, 1); + let (mut p, cid) = setup_rated(FrameBlend::FrameMix, (16, 8), fast, Some(FrameRate::FPS_30)); + let at = |k: i64| FrameRate::FPS_30.tick_of(k); + let keys = vec![Keyframe::new(at(0), Value::Scalar(0.0)).hold(), Keyframe::new(at(1), Value::Scalar(0.0)), Keyframe::new(at(31), Value::Scalar(1.0))]; + remap(&mut p, cid, keys); + let comp = p.comp(cid).unwrap().clone(); + let footage = comp.layers[0].id; + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 16, height: 8, pixel_aspect: 1.0 })); + let mut owner = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (16, 8), None); + super::add_effect(&mut p, &mut owner, "ec.channel.setmatte", [16.0, 8.0], &[("takeMatteFromLayer", Value::Layer(Some(footage.0)))]); + p.comp_mut(cid).unwrap().layers.push(owner); + let key = |k: i64| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(&p, cid, c, at(k)), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key(0), key(1)); +} + +/// The mix window is the source span of one comp frame, and falls back to the two-frame blend below four source frames per comp frame. +#[test] +fn mix_window_covers_the_source_span_of_one_comp_frame() { + let comp_frame = |r: FrameRate| r.frame_duration(); + let steady = |r: FrameRate| (comp_frame(r), comp_frame(r)); + // Fewer than four source frames per comp frame (slow-down, same rate, 60p in 30, 24 or 25, + // 50p in 25, 90 in 30): the two-frame blend applies, as before. + for (comp, footage) in [ + (FrameRate::FPS_30, FrameRate::FPS_30), + (FrameRate::FPS_30, FrameRate::FPS_24), + (FrameRate::FPS_30, FrameRate::new(36, 1)), + (FrameRate::FPS_25, FrameRate::FPS_30), + (FrameRate::FPS_30, FrameRate::FPS_60), + (FrameRate::FPS_24, FrameRate::FPS_60), + (FrameRate::FPS_25, FrameRate::FPS_60), + (FrameRate::FPS_25, FrameRate::FPS_50), + (FrameRate::FPS_29_97, FrameRate::FPS_59_94), + (FrameRate::FPS_30, FrameRate::new(90, 1)), + ] { + assert_eq!(mix_window(Tick::ZERO, steady(comp), footage), None, "{footage:?} in {comp:?}"); + } + // Four source frames per comp frame: 120 fps in a 30 fps comp mixes all four. + assert_eq!(mix_window(Tick::ZERO, steady(FrameRate::FPS_30), FrameRate::FPS_120), Some(vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)])); + // The report's case: 600 fps in a 25 fps comp, 24 source frames per comp frame, sampled at + // 16 evenly spread frames. + let w = mix_window(FrameRate::FPS_25.tick_of(3), steady(FrameRate::FPS_25), FrameRate::new(600, 1)).unwrap(); + assert_eq!(w.len(), MAX_MIX_FRAMES); + assert!(w.iter().all(|x| (72..96).contains(&x.0) && x.1 == 1.0), "{w:?}"); + assert!(w.windows(2).all(|p| p[0].0 < p[1].0), "{w:?}"); + // 150 fps in a 30 fps comp: comp frame 1 spans source frames 5 … 10. + let w = mix_window(FrameRate::FPS_30.tick_of(1), steady(FrameRate::FPS_30), FrameRate::new(150, 1)); + assert_eq!(w, Some((5..10).map(|j| (j, 1.0)).collect())); + // 135 fps in a 30 fps comp: comp frame 1 spans source frames 4.5 … 9.0. + let w = mix_window(FrameRate::FPS_30.tick_of(1), steady(FrameRate::FPS_30), FrameRate::new(135, 1)).unwrap(); + assert_eq!(w.iter().map(|x| x.0).collect::>(), vec![4, 5, 6, 7, 8]); + for (got, want) in w.iter().map(|x| x.1).zip([0.5, 1.0, 1.0, 1.0, 1.0]) { + assert!((got - want).abs() < 1e-9, "{w:?}"); + } + // Playing backwards: the span ends with the comp frame's own source frame. + let back = -comp_frame(FrameRate::FPS_30); + let w = mix_window(FrameRate::FPS_30.tick_of(1), (back, back), FrameRate::new(300, 1)).unwrap(); + assert_eq!(w.iter().map(|x| x.0).collect::>(), (1..=10).collect::>()); + // A hold (no local speed) or a jump (local speed and the whole frame disagree) does not mix. + let f = comp_frame(FrameRate::FPS_30); + assert_eq!(mix_window(Tick::ZERO, (Tick::ZERO, f), FrameRate::new(300, 1)), None); + assert_eq!(mix_window(Tick::ZERO, (f, -f), FrameRate::new(300, 1)), None); + // A long span is sampled at a bounded number of frames; a span beyond any footage's speed is + // a jump. + let ten = Tick::from_seconds_f64(10.0); + let w = mix_window(Tick::ZERO, (ten, ten), FrameRate::FPS_60).unwrap(); + assert_eq!(w.len(), MAX_MIX_FRAMES); + assert!(w.iter().all(|x| (0..600).contains(&x.0))); + let far = Tick::from_seconds_f64(100.0); + assert_eq!(mix_window(Tick::ZERO, (far, far), FrameRate::FPS_60), None); + // Degenerate rates and huge spans don't panic. + assert_eq!(mix_window(Tick::ZERO, (Tick::MAX, Tick::MAX), FrameRate { num: 0, den: 1 }), None); + assert_eq!(mix_window(Tick::MIN, (Tick::MAX, Tick::MAX), FrameRate::FPS_60), None); + assert_eq!(mix_window(Tick::MAX, (Tick::MIN, Tick::MAX), FrameRate::FPS_60), None); +} + #[test] fn frame_position_survives_degenerate_rates() { for rate in [FrameRate { num: 24, den: 0 }, FrameRate { num: 0, den: 1 }, FrameRate { num: -24, den: 1 }] {