From bfa21e32224029e095761be2471749c0af0f80df Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:05:52 -0400 Subject: [PATCH] M4.3: frame blending Frame Mix and Pixel Motion for footage and precomp layers when the layer's Frame Blending switch and the comp's Enable Frame Blending are on and the source time falls between source frames (rate conform, time stretch, time remap). Exact frame positions from tick arithmetic. Pixel Motion is a hierarchical block-matching optical flow (SAD search, sub-pixel parabola fit, vector median) with a bidirectional warp-blend (raster::flow). Blended footage frames are cached. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 4 +- crates/raster/src/flow.rs | 290 +++++++++++++++++++++++++ crates/raster/src/lib.rs | 1 + crates/render/src/cache.rs | 2 + crates/render/src/lib.rs | 74 ++++++- crates/render/src/tests_frame_blend.rs | 168 ++++++++++++++ 6 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 crates/raster/src/flow.rs create mode 100644 crates/render/src/tests_frame_blend.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5bc51716..564dbc1b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -518,10 +518,10 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] -mod tests_fidelity; -#[cfg(test)] mod tests_effects; #[cfg(test)] +mod tests_fidelity; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_menu_cmds; diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs new file mode 100644 index 00000000..c398eadd --- /dev/null +++ b/crates/raster/src/flow.rs @@ -0,0 +1,290 @@ +//! Optical flow by block matching, and motion-compensated frame interpolation (the Pixel Motion +//! mode of frame blending). +//! +//! The method is the textbook hierarchical block matcher used in video coding and frame-rate +//! conversion: build a luma pyramid of both frames; at the coarsest level, find for each block of +//! frame A the integer displacement into frame B with the smallest sum of absolute differences +//! (SAD) in a search window; at each finer level, start from the doubled coarse vectors (and the +//! neighbours' vectors) and refine in a small window; finish with a sub-pixel parabola fit of the +//! SAD around the best vector and a 3×3 vector median to remove outliers. +//! +//! Interpolation at a fraction `w` between the frames warps both towards the in-between time — +//! A forward by `w·F`, B back by `(1 − w)·F` — and cross-fades them, so moving detail stays sharp +//! instead of double-exposing as with a plain frame mix. + +use rayon::prelude::*; + +use crate::Image; + +/// A block motion field: one vector per `block × block` tile of frame A (in pixels, A → B). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Flow { + pub width: u32, + pub height: u32, + pub block: u32, + /// Tiles across / down. + pub cols: usize, + pub rows: usize, + pub v: Vec<[f32; 2]>, +} + +impl Flow { + /// The motion at pixel position (x, y), bilinearly interpolated between tile centres. + pub fn at(&self, x: f64, y: f64) -> [f32; 2] { + if self.v.is_empty() { + return [0.0; 2]; + } + let b = self.block as f64; + let fx = (x / b - 0.5).clamp(0.0, (self.cols - 1) as f64); + let fy = (y / b - 0.5).clamp(0.0, (self.rows - 1) as f64); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.cols - 1), (y0 + 1).min(self.rows - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let g = |c: usize, r: usize| self.v[r * self.cols + c]; + let mut o = [0.0; 2]; + for i in 0..2 { + let top = g(x0, y0)[i] + (g(x1, y0)[i] - g(x0, y0)[i]) * tx; + let bot = g(x0, y1)[i] + (g(x1, y1)[i] - g(x0, y1)[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o + } +} + +/// A single-channel plane. +struct Plane { + w: usize, + h: usize, + d: Vec, +} + +impl Plane { + fn luma(img: &Image) -> Plane { + let d = img.data.par_iter().map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3]).collect(); + Plane { w: img.width as usize, h: img.height as usize, d } + } + fn half(&self) -> Plane { + let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1)); + let d = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = (i % w * 2, i / w * 2); + let g = |dx: usize, dy: usize| self.d[(y + dy).min(self.h - 1) * self.w + (x + dx).min(self.w - 1)]; + (g(0, 0) + g(1, 0) + g(0, 1) + g(1, 1)) * 0.25 + }) + .collect(); + Plane { w, h, d } + } + #[inline] + fn get(&self, x: i64, y: i64) -> f32 { + self.d[y.clamp(0, self.h as i64 - 1) as usize * self.w + x.clamp(0, self.w as i64 - 1) as usize] + } +} + +/// SAD of the `bs` block at (bx, by) in `a` against `b` displaced by (dx, dy). +fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 { + let mut s = 0.0; + for y in by..by + bs { + for x in bx..bx + bs { + s += (a.get(x, y) - b.get(x + dx, y + dy)).abs(); + } + } + s +} + +/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; +/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). +pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { + let bs = block.max(4) as usize; + let (w, h) = (a.width as usize, a.height as usize); + let cols = w.div_ceil(bs).max(1); + let rows = h.div_ceil(bs).max(1); + let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; + if a.width != b.width || a.height != b.height || w < bs || h < bs { + return flow; + } + // Pyramids (level 0 = full resolution); stop while blocks still fit. + let mut pa = vec![Plane::luma(a)]; + let mut pb = vec![Plane::luma(b)]; + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); + pa.extend(na); + pb.extend(nb); + } + let top = pa.len() - 1; + // Integer vectors at the current level, per full-resolution tile. + let mut cur: Vec<[i64; 2]> = vec![[0, 0]; cols * rows]; + for level in (0..=top).rev() { + let (la, lb) = (&pa[level], &pb[level]); + let k = 1i64 << level; + // The block covers the tile's footprint at this level (at least 4 pixels). + let lbs = ((bs as i64) / k).max(4); + let r = if level == top { radius as i64 } else { 1 }; + let prev = cur.clone(); + cur = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, rr) = ((i % cols) as i64, (i / cols) as i64); + let bx = c * bs as i64 / k; + let by = rr * bs as i64 / k; + // Candidates: this tile's and its neighbours' vectors from the coarser level. + let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; + if level != top { + for (dc, dr) in [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)] { + let (nc, nr) = (c + dc, rr + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = prev[nr as usize * cols + nc as usize]; + seeds.push([v[0] * 2, v[1] * 2]); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + let mut best = ([0i64, 0i64], f32::INFINITY); + for s in seeds { + for dy in -r..=r { + for dx in -r..=r { + let d = [s[0] + dx, s[1] + dy]; + // A tiny bias towards short vectors breaks ties in flat areas. + let e = sad(la, lb, bx, by, lbs, d[0], d[1]) + 1e-4 * (d[0].abs() + d[1].abs()) as f32; + if e < best.1 { + best = (d, e); + } + } + } + } + best.0 + }) + .collect(); + } + // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. + let (la, lb) = (&pa[0], &pb[0]); + let bsi = bs as i64; + let fine: Vec<[f32; 2]> = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let d = cur[i]; + let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let fit = |em: f32, ep: f32| { + let den = em - 2.0 * e0 + ep; + if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + [d[0] as f32 + sx, d[1] as f32 + sy] + }) + .collect(); + // 3×3 vector median (component-wise) against outliers. + flow.v = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, r) = ((i % cols) as i64, (i / cols) as i64); + let mut xs = Vec::with_capacity(9); + let mut ys = Vec::with_capacity(9); + for dr in -1..=1 { + for dc in -1..=1 { + let (nc, nr) = (c + dc, r + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = fine[nr as usize * cols + nc as usize]; + xs.push(v[0]); + ys.push(v[1]); + } + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[xs.len() / 2], ys[ys.len() / 2]] + }) + .collect(); + flow +} + +/// Cross-fade of two frames: `a·(1 − w) + b·w` (Frame Mix). +pub fn mix(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return if w < 0.5 { a.clone() } else { b.clone() }; + } + let mut out = a.clone(); + out.data.par_iter_mut().zip(b.data.par_iter()).for_each(|(p, q)| { + for c in 0..4 { + p[c] += (q[c] - p[c]) * w; + } + }); + out +} + +/// Motion-compensated frame at fraction `w` (0 = `a`, 1 = `b`) between two frames (Pixel Motion). +pub fn interpolate(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return mix(a, b, w); + } + let flow = block_flow(a, b, 8, 4); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = a.sample_bilinear_clamped(cx - wd * fx, cy - wd * fy); + let pb = b.sample_bilinear_clamped(cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured image shifted right by `dx` pixels. + fn texture(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = 0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.013 * v * 0.011).sin(); + let s = s as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img + } + + fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn flow_finds_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let f = block_flow(&a, &b, 8, 4); + let m = f.at(64.0, 48.0); + assert!((m[0] - 6.0).abs() < 0.3 && m[1].abs() < 0.3, "{m:?}"); + } + + #[test] + fn pixel_motion_beats_frame_mix_on_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let truth = texture(128, 96, 3.0); + let pm = mae(&interpolate(&a, &b, 0.5), &truth, 12); + let fm = mae(&mix(&a, &b, 0.5), &truth, 12); + assert!(pm < fm * 0.25, "pixel motion {pm} vs frame mix {fm}"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 643de9e7..dfe53848 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,6 +4,7 @@ //! heavy operation is row-parallel with rayon. pub mod blur; +pub mod flow; pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index a26e652c..8cd27d59 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -288,6 +288,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool hash_debug(&mut h, f); item.hash(&mut h); ctx.source_time(layer).0.hash(&mut h); + // Frame blending mixes neighbouring source frames. + ctx.comp.enable_frame_blending.hash(&mut h); } _ => return None, } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d60a1ee8..b200386e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -22,10 +22,10 @@ pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; use effectcraft_geom::{Mat3, vec2}; -use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; +use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; pub use effectcraft_raster::Image; use effectcraft_raster::{WarpOpts, composite_warp}; -use effectcraft_time::Tick; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, source_size}; use rayon::prelude::*; @@ -327,6 +327,18 @@ impl<'a> Renderer<'a> { } let sub = self.nested(); let lt = ctx.source_time(layer); + // Frame blending between the nested comp's frames (time-stretched/remapped). + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off + && let Some(nc) = self.project.comp(*item) + { + let (i, w) = frame_position(lt, nc.frame_rate); + if w > 1e-6 { + let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i)); + let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1)); + return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s }); + } + } Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -365,8 +377,37 @@ impl<'a> Renderer<'a> { buf } - /// A footage frame at source time `t`. - fn footage_frame(&self, _ctx: &EvalCtx, _layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + /// A footage frame at source time `t`, frame-blended between the two nearest source frames + /// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and + /// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping). + fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { + 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).hash(&mut h); + h.finish() + }); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(Arc::new(b.img.clone())); + } + let a = self.footage.frame(item, f, f.frame_rate.tick_of(i)); + let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1)); + if let (Some(a), Some(b)) = (a, b) { + let img = blend_frames(mode, &a, &b, w as f32); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); + } + return Some(Arc::new(img)); + } + } + } self.footage.frame(item, f, t) } @@ -723,6 +764,29 @@ struct StyledLayer { plain: bool, } +/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. +fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend { + if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off } +} + +/// Position of source time `t` among frames at `rate`: the frame at or before `t` and the +/// fraction (0..1) of the way to the next frame. Exact (integer tick arithmetic), so frames that +/// line up exactly report a fraction of 0. +pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) { + let num = t.0 as i128 * rate.num as i128; + let den = TICKS_PER_SECOND as i128 * rate.den as i128; + (num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64) +} + +/// 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 { + match mode { + FrameBlend::PixelMotion => effectcraft_raster::flow::interpolate(a, b, w), + _ => effectcraft_raster::flow::mix(a, b, w), + } +} + /// Convenience: render one frame of a comp with no footage source. pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) @@ -763,6 +827,8 @@ mod tests_audio_fx; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_frame_blend; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs new file mode 100644 index 00000000..c172b81c --- /dev/null +++ b/crates/render/src/tests_frame_blend.rs @@ -0,0 +1,168 @@ +//! Frame blending (Frame Mix and Pixel Motion) for footage and precomp layers. + +use std::sync::Arc; + +use rayon::prelude::*; + +use effectcraft_color::Label; +use effectcraft_keyframe::{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}; + +#[test] +fn frame_mix_weights_for_24_to_30_conform() { + // Comp frame k at 30 fps sits at source frame 0.8·k of 24 fps footage. + let want = [(0, 0.0), (0, 0.8), (1, 0.6), (2, 0.4), (3, 0.2), (4, 0.0), (4, 0.8)]; + for (k, (i, w)) in want.into_iter().enumerate() { + let t = FrameRate::FPS_30.tick_of(k as i64); + let (fi, fw) = frame_position(t, FrameRate::FPS_24); + assert_eq!(fi, i, "comp frame {k}"); + assert!((fw - w).abs() < 1e-12, "comp frame {k}: {fw} vs {w}"); + } + // NTSC 29.97 footage in a 29.97 comp lines up exactly. + for k in [0, 1, 7, 1001, 30_000] { + assert_eq!(frame_position(FrameRate::FPS_29_97.tick_of(k), FrameRate::FPS_29_97), (k, 0.0)); + } +} + +/// Footage whose frame `i` is a flat grey of `i / 10` (24 fps), or a textured pattern moving +/// 6 px right per frame. +struct Frames { + moving: bool, +} + +fn pattern(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos()) as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img +} + +impl FootageSource for Frames { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { + let i = f.frame_rate.frame_at(t); + if self.moving { + return Some(Arc::new(pattern(f.width, f.height, 6.0 * i as f64))); + } + let g = i as f32 / 10.0; + Some(Arc::new(Image::filled(f.width, f.height, [g, g, g, 1.0]))) + } +} + +fn setup(mode: FrameBlend, size: (u32, u32)) -> (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)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "clip.mov".into(), + kind: FootageKind::Video, + width: size.0, + height: size.1, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_24, + native_rate: None, + duration: Tick::from_seconds_f64(2.0), + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: None, + }; + let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None); + l.switches.frame_blend = mode; + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, k: i64, moving: bool) -> Image { + let src = Frames { moving }; + Renderer::new(p, &src, RenderOpts::default()).comp_frame(cid, FrameRate::FPS_30.tick_of(k)) +} + +#[test] +fn frame_mix_blends_footage_frames() { + let (p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + // Comp frame 1 → source 0.8: 0.2·frame 0 + 0.8·frame 1 = 0.08. + let v = frame(&p, cid, 1, false).get(4, 4)[0]; + assert!((v - 0.08).abs() < 1e-6, "{v}"); + let v = frame(&p, cid, 2, false).get(4, 4)[0]; + assert!((v - (0.4 * 0.1 + 0.6 * 0.2)).abs() < 1e-6, "{v}"); + // Off (layer switch or comp switch): the frame at or before. + let (p, cid) = setup(FrameBlend::Off, (16, 8)); + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); + let (mut p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); +} + +fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 +} + +#[test] +fn pixel_motion_beats_frame_mix_on_moving_footage() { + let size = (128, 96); + // Comp frame 2 → source frame 1.6: the pattern 9.6 px along. + let truth = pattern(size.0, size.1, 6.0 * 1.6); + let (p, cid) = setup(FrameBlend::FrameMix, size); + let mix = mae(&frame(&p, cid, 2, true), &truth, 14); + let (p, cid) = setup(FrameBlend::PixelMotion, size); + let pm = mae(&frame(&p, cid, 2, true), &truth, 14); + assert!(pm < mix * 0.3, "pixel motion {pm} vs frame mix {mix}"); +} + +#[test] +fn time_stretched_precomp_frame_mixes() { + // Inner comp: a white solid moving 10 px per frame from x = 10. The precomp is stretched to + // 200%, so odd outer frames fall halfway between inner frames. + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let mut inner = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 20), None); + // Anchor at the left edge: x = 10 + 10 per frame. + l.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 10.0, 0.0]); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([310.0, 10.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let outer = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(4.0)); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (400, 20), None); + pre.stretch = 200.0; + pre.out_point = Tick::from_seconds_f64(4.0); + pre.switches.frame_blend = FrameBlend::FrameMix; + p.comp_mut(cid).unwrap().layers.push(pre); + // Outer frame 1 → inner frame 0.5: solids at 10..20 (frame 0) and 20..30 (frame 1), half each. + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + for x in [12, 17, 22, 27] { + assert!((img.get(x, 10)[3] - 0.5).abs() < 5e-3, "x {x}: {:?}", img.get(x, 10)); + } + // Without frame blending the nested comp renders at the in-between time: one solid, 15..25. + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + 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); +}