filmcraft/crates/render/src/preview_tests.rs

303 lines
13 KiB
Rust

//! Render-bar segments: splitting, hashing, invalidation and cost classification.
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, Generator, MediaSource};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, find_effect};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use crate::preview::{Need, Segment, audio_segments, effect_cost_ms, segment_at, video_segments};
fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId {
let info = g.info().clone();
p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(g.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
)
}
fn setup() -> (Project, ItemId, ItemId, ItemId) {
let mut p = Project::new("r");
let matte = add_gen(
&mut p,
GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, 1920, 1080, FrameRate::FPS_24, Tick(60 * TICKS_PER_SECOND)),
);
let ocean = add_gen(&mut p, GeneratorSource::demo(DemoScene::OceanSunset));
let seq = p.new_sequence("s", SequenceSettings { width: 1920, height: 1080, frame_rate: FrameRate::FPS_24, ..Default::default() }, 3, 1, None);
(p, matte, ocean, seq)
}
fn place(p: &mut Project, seq: ItemId, track: usize, item: ItemId, start_f: i64, dur_f: i64) -> ClipId {
let r = FrameRate::FPS_24;
let mut ti = p.make_track_item(item, TrackKind::Video, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap();
for e in ti.effects.iter_mut() {
filmcraft_project::resolve_auto_points(e, (1920, 1080), (1920, 1080));
}
let id = ti.id;
let t = &mut p.sequence_mut(seq).unwrap().video_tracks[track];
t.items.push(ti);
t.sort();
id
}
fn add_fx(p: &mut Project, seq: ItemId, clip: ClipId, id: &str) {
let mut inst = find_effect(id).unwrap().instance();
filmcraft_project::resolve_auto_points(&mut inst, (1920, 1080), (1920, 1080));
p.sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1.effects.push(inst);
}
fn bounds(s: &[Segment]) -> Vec<(i64, i64)> {
s.iter().map(|s| (s.first_frame, s.first_frame + s.frames)).collect()
}
fn by_frame(s: &[Segment], f: i64) -> String {
segment_at(s, f).unwrap().hash.clone()
}
#[test]
fn segments_split_at_every_edit_point_and_skip_gaps() {
let (mut p, matte, ocean, seq) = setup();
place(&mut p, seq, 0, ocean, 0, 48);
place(&mut p, seq, 0, matte, 48, 24);
// gap 72..96 on every track
place(&mut p, seq, 0, ocean, 96, 24);
place(&mut p, seq, 1, matte, 24, 48); // spans the V1 cut at 48
let s = video_segments(&p, seq);
assert_eq!(bounds(&s), vec![(0, 24), (24, 48), (48, 72), (96, 120)]);
assert_eq!(s[1].clips.len(), 2, "two visible layers in 24..48");
assert!(segment_at(&s, 80).is_none(), "gap has no segment");
assert_eq!(segment_at(&s, 30).unwrap().first_frame, 24);
// segments are frame-aligned ranges
let r = FrameRate::FPS_24;
for seg in &s {
assert_eq!(seg.start, r.tick_of(seg.first_frame));
assert_eq!(seg.end, r.tick_of(seg.first_frame + seg.frames));
}
}
#[test]
fn transitions_are_their_own_segments() {
let (mut p, matte, ocean, seq) = setup();
let a = place(&mut p, seq, 0, ocean, 0, 48);
let b = place(&mut p, seq, 0, matte, 48, 48);
let r = FrameRate::FPS_24;
let tr = filmcraft_project::Transition {
id: Default::default(),
effect: find_effect("cross_dissolve").unwrap().instance(),
start: r.tick_of(36),
duration: r.tick_of(24),
from: Some(a),
to: Some(b),
align: Default::default(),
reverse: false,
};
p.sequence_mut(seq).unwrap().video_tracks[0].transitions.push(tr);
let s = video_segments(&p, seq);
assert_eq!(bounds(&s), vec![(0, 36), (36, 48), (48, 60), (60, 96)]);
assert_eq!(s[1].clips, vec![a, b]);
assert_eq!(s[1].need, Need::Realtime);
}
#[test]
fn editing_one_clip_invalidates_only_its_segments() {
let (mut p, matte, ocean, seq) = setup();
let a = place(&mut p, seq, 0, ocean, 0, 48);
let b = place(&mut p, seq, 0, matte, 48, 48);
add_fx(&mut p, seq, a, "gaussian_blur");
add_fx(&mut p, seq, b, "tint");
let before = video_segments(&p, seq);
// change a parameter of A's effect
let q = p.sequence_mut(seq).unwrap();
let (_, it) = q.find_item_mut(a).unwrap();
it.effect_mut("gaussian_blur").unwrap().params.values_mut().next().unwrap().value = ParamValue::Float(42.0);
let after = video_segments(&p, seq);
assert_ne!(by_frame(&before, 10), by_frame(&after, 10), "A changed");
assert_eq!(by_frame(&before, 60), by_frame(&after, 60), "B untouched");
// a keyframe on B changes B only
let q = p.sequence_mut(seq).unwrap();
let (_, it) = q.find_item_mut(b).unwrap();
it.effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().toggle_animation(Tick(1000));
let kf = video_segments(&p, seq);
assert_eq!(by_frame(&after, 10), by_frame(&kf, 10));
assert_ne!(by_frame(&after, 60), by_frame(&kf, 60));
// sequence settings affect everything
p.sequence_mut(seq).unwrap().settings.max_render_quality = true;
let st = video_segments(&p, seq);
assert_ne!(by_frame(&kf, 10), by_frame(&st, 10));
assert_ne!(by_frame(&kf, 60), by_frame(&st, 60));
}
#[test]
fn renaming_relabelling_and_moving_keep_hashes() {
let (mut p, matte, ocean, seq) = setup();
let a = place(&mut p, seq, 0, ocean, 0, 48);
let b = place(&mut p, seq, 0, matte, 48, 48);
add_fx(&mut p, seq, a, "tint");
let before = video_segments(&p, seq);
let q = p.sequence_mut(seq).unwrap();
for it in q.video_tracks[0].items.iter_mut() {
it.name = "renamed".into();
it.label = Label::Mango;
// ripple both clips 10 frames later
it.start += FrameRate::FPS_24.tick_of(10);
}
let after = video_segments(&p, seq);
assert_eq!(by_frame(&before, 0), by_frame(&after, 10), "moved segment keeps its preview");
assert_eq!(by_frame(&before, 50), by_frame(&after, 60));
let _ = b;
// trimming B changes B's segment only (its frame count changed)
let q = p.sequence_mut(seq).unwrap();
q.find_item_mut(b).unwrap().1.duration = FrameRate::FPS_24.tick_of(40);
let trimmed = video_segments(&p, seq);
assert_eq!(by_frame(&after, 10), by_frame(&trimmed, 10));
assert_ne!(by_frame(&after, 60), by_frame(&trimmed, 60));
}
#[test]
fn hashes_are_stable_across_save_and_load() {
let (mut p, matte, ocean, seq) = setup();
let a = place(&mut p, seq, 0, ocean, 0, 48);
let b = place(&mut p, seq, 1, matte, 24, 48);
add_fx(&mut p, seq, a, "lumetri");
add_fx(&mut p, seq, b, "gaussian_blur");
let q = p.sequence_mut(seq).unwrap();
let (_, it) = q.find_item_mut(b).unwrap();
let m = it.effect_mut("motion").unwrap();
m.params.get_mut("scale").unwrap().toggle_animation(Tick(0));
m.params.get_mut("scale").unwrap().set_at(Tick(TICKS_PER_SECOND), ParamValue::Float(80.0));
let before = video_segments(&p, seq);
let loaded = Project::from_json(&p.to_json()).unwrap();
let after = video_segments(&loaded, seq);
assert_eq!(before, after);
// and identical across repeated computation
assert_eq!(video_segments(&p, seq), before);
}
#[test]
fn need_classification_none_yellow_red() {
let (mut p, matte, _ocean, seq) = setup();
let a = place(&mut p, seq, 0, matte, 0, 24);
let b = place(&mut p, seq, 0, matte, 24, 24);
let c = place(&mut p, seq, 0, matte, 48, 24);
add_fx(&mut p, seq, b, "tint");
for fx in ["gaussian_blur", "sharpen", "lumetri", "noise"] {
add_fx(&mut p, seq, c, fx);
}
let s = video_segments(&p, seq);
let need = |f| segment_at(&s, f).unwrap().need;
assert_eq!(need(0), Need::None, "untouched clip matching the sequence plays natively");
assert_eq!(need(30), Need::Realtime, "one cheap effect: yellow");
assert_eq!(need(60), Need::Render, "four heavy effects: red ({} ms)", segment_at(&s, 60).unwrap().cost_ms);
let _ = a;
// a clip at a different frame size than the sequence needs scaling: yellow
let (mut p, _m, ocean, seq) = setup();
place(&mut p, seq, 0, ocean, 0, 24);
let s = video_segments(&p, seq);
let ocean_matches = p.item(ocean).unwrap().as_media().unwrap().info.video.as_ref().is_some_and(|v| v.width == 1920 && v.height == 1080);
let want = if ocean_matches && p.item(ocean).unwrap().frame_rate() == FrameRate::FPS_24 { Need::None } else { Need::Realtime };
assert_eq!(s[0].need, want);
}
#[test]
fn effect_tiers_are_ordered() {
assert!(effect_cost_ms("lumetri") > effect_cost_ms("tint"));
assert!(effect_cost_ms("tint") > effect_cost_ms("crop"));
assert_eq!(effect_cost_ms("gaussian_blur"), effect_cost_ms("lumetri"));
}
/// Times every standard video effect on a 1080p frame (run with `--release --ignored --nocapture`)
/// to calibrate [`effect_cost_ms`].
#[test]
#[ignore]
fn effect_costs() {
use crate::effects::{FxCtx, apply};
let mut rows = Vec::new();
for d in filmcraft_project::effect_defs().iter().filter(|d| d.kind == filmcraft_project::EffectKind::Video && !d.intrinsic) {
let mut img = crate::Image::filled(1920, 1080, [0.4, 0.5, 0.6, 1.0]);
let e = d.instance();
let cx = FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "00:00:00:00",
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
let t0 = std::time::Instant::now();
for _ in 0..3 {
apply(&mut img, &e, &cx);
}
rows.push((t0.elapsed().as_secs_f64() * 1000.0 / 3.0, d.id));
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
for (ms, id) in rows {
println!("{ms:8.2} ms {id:24} tier {:.0}", effect_cost_ms(id));
}
}
/// Put `item` on track 0 of `kind` in `seq` from frame `start_f` for `dur_f` frames.
fn place_kind(p: &mut Project, seq: ItemId, kind: TrackKind, item: ItemId, start_f: i64, dur_f: i64) -> ClipId {
let r = FrameRate::FPS_24;
let ti = p.make_track_item(item, kind, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap();
let id = ti.id;
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[0];
t.items.push(ti);
t.sort();
id
}
fn audio_hashes(p: &Project, seq: ItemId) -> Vec<String> {
audio_segments(p, seq).into_iter().map(|g| g.hash).collect()
}
#[test]
fn a_nests_audio_previews_follow_the_sound_of_its_sequence() {
// Inner has a picture clip and a sound clip; Outer nests Inner (picture and sound), and Top
// nests Outer's sound
let (mut p, matte, ocean, inner) = setup();
let pic = place_kind(&mut p, inner, TrackKind::Video, matte, 0, 48);
let snd = place_kind(&mut p, inner, TrackKind::Audio, ocean, 0, 48);
let settings = p.sequence(inner).unwrap().settings.clone();
let outer = p.new_sequence("outer", settings.clone(), 1, 1, None);
place_kind(&mut p, outer, TrackKind::Video, inner, 24, 48);
place_kind(&mut p, outer, TrackKind::Audio, inner, 24, 48);
let top = p.new_sequence("top", settings, 1, 1, None);
place_kind(&mut p, top, TrackKind::Audio, outer, 12, 72);
let (audio, picture, deep) = (audio_hashes(&p, outer), video_segments(&p, outer), audio_hashes(&p, top));
assert_eq!(audio.len(), 1);
// the sound inside the nest changes: the nest's audio preview is stale, its picture is not
p.sequence_mut(inner).unwrap().find_item_mut(snd).unwrap().1.gain_db = -12.0;
assert_ne!(audio_hashes(&p, outer), audio, "a clip's gain inside the nest");
assert_ne!(audio_hashes(&p, top), deep, "a clip's gain two nests down");
assert_eq!(bounds(&video_segments(&p, outer)), bounds(&picture));
assert_eq!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30), "the picture did not change");
let (audio, deep) = (audio_hashes(&p, outer), audio_hashes(&p, top));
// the nested sequence's track mix counts too
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = true;
assert_ne!(audio_hashes(&p, outer), audio, "a muted track inside the nest");
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = false;
assert_eq!(audio_hashes(&p, outer), audio);
// the picture inside the nest changes: its audio preview stays valid
add_fx(&mut p, inner, pic, "tint");
assert_ne!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30));
assert_eq!(audio_hashes(&p, outer), audio, "a video effect inside the nest");
assert_eq!(audio_hashes(&p, top), deep);
}