Merge pull request #3211 from vangel76/frame-blend-interpret-3180

Frame blending averages the source frames a comp frame skips over (fast-conformed footage)
This commit is contained in:
FredApps
2026-11-14 17:04:22 +01:00
committed by GitHub
6 changed files with 351 additions and 23 deletions
+10
View File
@@ -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,
}
+6 -1
View File
@@ -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);
+78
View File
@@ -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<Vec<(i64, f64)>> {
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 {
+1 -1
View File
@@ -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;
+79 -17
View File
@@ -93,6 +93,26 @@ pub struct Collapsed {
pub(crate) media_overrides: Vec<MediaOverride>,
}
/// 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<Arc<Image>> {
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<Image> = 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<Buf> {
let (mut buf, quantized) = match self.content_times(ctx, layer) {
+177 -4
View File
@@ -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<FrameRate>) -> (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<Keyframe>) {
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<_>>(), 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::<Vec<_>>(), (1..=10).collect::<Vec<_>>());
// 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 }] {