mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
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
This commit is contained in:
1 parent
bb2d2005ce
commit
82cef51617
6 files changed
+341
-20
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,23 @@ 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);
|
||||
(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<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(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<Image> = 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<Buf> {
|
||||
let mut buf = match self.content_times(ctx, layer) {
|
||||
|
||||
@@ -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<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 +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<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));
|
||||
}
|
||||
|
||||
#[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<_>>(), 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 }] {
|
||||
|
||||
Reference in new issue
Block a user