From 82cef516176f5f9c0aa38f4a78806be0b59b4948 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 12:24:04 +0100 Subject: [PATCH 1/2] Frame blending averages the source frames a comp frame skips over (#3180) Footage interpreted at a much higher frame rate than the comp (for example a 60 fps clip interpreted as 600 fps in a 25 fps comp) showed no frame blending. Frame Mix only cross-faded the two source frames around the comp frame's source time, and only when that time fell between frames. With a whole-number ratio every comp frame lands exactly on a source frame, so nothing blended, and at other ratios at most two of the many skipped frames were used. When a comp frame runs through at least four source frames (conformed rate, time stretch or time remap), Frame Mix and Pixel Motion now average the source frames that comp frame spans, each weighted by how much of it the span covers; spans over 16 frames are sampled at 16 evenly spread frames. Played backwards, the span ends with the comp frame's own source frame. The speed is measured locally (over 1/16 of a comp frame) and must agree with the difference to the next comp frame, so a time remap jump (a hold followed by a cut, a loop wrapping back) stays crisp instead of smearing across the jump. Reduced-resolution previews mix frames at the reduced size. Below four source frames per comp frame (slow-downs, equal rates, 60p in a 30, 25 or 24 fps comp, 50p in 25) nothing changes. The threshold and the 16-frame cap are named constants, pending a comparison with After Effects. The layer cache key, and the key of a layer that selects a frame-blended footage layer as an effect input, now include how far the source runs during the comp frame, which the mix depends on. Fixes #3180 --- crates/render/src/cache.rs | 10 ++ crates/render/src/cache/dependencies.rs | 5 +- crates/render/src/frames.rs | 78 +++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/pipeline.rs | 91 ++++++++++-- crates/render/src/tests/frame_blend.rs | 175 +++++++++++++++++++++++- 6 files changed, 341 insertions(+), 20 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index cf1a84e82..b6c38f80d 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -477,6 +477,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 @@ -646,6 +655,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 db1465c07..c8c483f99 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -165,7 +165,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 dbd948320..0a8476344 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -49,7 +49,7 @@ pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem}; 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 e84348fdf..6012a7404 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -50,6 +50,23 @@ 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); + (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). @@ -848,23 +865,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); - (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(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -885,6 +895,59 @@ impl<'a> Renderer<'a> { 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(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + 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 mut acc: Option = None; + let mut total = 0.0; + for (j, w) in frames { + let Some(img) = self.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; + } + 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; + } + }); + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { + c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); + } + Some(Arc::new(img)) + } + /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { let mut buf = 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..e80731d35 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() { @@ -58,6 +59,11 @@ impl FootageSource for Frames { } 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 +74,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 +174,167 @@ 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); } +#[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); +} + +#[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)); +} + +#[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}"); +} + +#[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)); +} + +#[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 }] { From 2a00e26be5f6ea39fcfc007ad420e0d7f1e61780 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 19:50:27 +0100 Subject: [PATCH 2/2] Doc comments for the functions this PR touches --- crates/render/src/cache/dependencies.rs | 2 ++ crates/render/src/pipeline.rs | 2 ++ crates/render/src/tests/frame_blend.rs | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index c8c483f99..c1ff20565 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -122,6 +122,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) { diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6012a7404..89ce84f7f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -861,6 +861,8 @@ impl<'a> Renderer<'a> { if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } + /// The footage frame at source time `t` after frame blending: the average of the source frames one comp frame spans, else a blend of the two neighbouring frames, else the plain frame. + /// Log scans are converted first, and blended frames are cached. fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); let float = self.pipe.float_curve(f); diff --git a/crates/render/src/tests/frame_blend.rs b/crates/render/src/tests/frame_blend.rs index e80731d35..ecee73b6d 100644 --- a/crates/render/src/tests/frame_blend.rs +++ b/crates/render/src/tests/frame_blend.rs @@ -58,6 +58,7 @@ 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) } @@ -174,6 +175,7 @@ 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 @@ -195,6 +197,7 @@ fn frame_mix_averages_every_frame_of_fast_conformed_footage() { 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. @@ -226,6 +229,7 @@ fn remap(p: &mut Project, cid: ItemId, keys: Vec) { 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); @@ -253,6 +257,7 @@ fn time_remap_jumps_stay_crisp() { 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 @@ -276,6 +281,7 @@ fn a_frame_blended_dependency_keys_how_far_its_source_runs() { 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();