diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index 8079502b7..7f5c24307 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -39,7 +39,7 @@ //! size and rate match the sequence. Green (a valid preview exists) is decided by the preview //! store, which knows which hashes have files. -use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, Transition}; +use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, TrackKind, Transition}; use filmcraft_time::{FrameRate, Tick}; use serde::Serialize; @@ -166,7 +166,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec { match l { Layer::Item(ti, it) => { h.json(&("item", ti)); - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Video, 0); absolute |= prints_time(it); clips.push(it.id); } @@ -180,7 +180,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec { for side in [a, b] { match side { Some(it) => { - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Video, 0); absolute |= prints_time(it); clips.push(it.id); } @@ -277,7 +277,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec { for side in [x.from, x.to] { match side.and_then(|id| tr.item(id)).filter(|i| i.enabled) { Some(it) => { - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Audio, 0); heard = true; } None => h.json(&"none"), @@ -285,7 +285,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec { } } else if let Some(it) = tr.item_at(t).filter(|i| i.enabled) { h.json(&("item", ti)); - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Audio, 0); heard = true; } } @@ -300,7 +300,9 @@ fn prints_time(it: &TrackItem) -> bool { it.effects.iter().any(|e| e.enabled && e.effect == "timecode") } -fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) { +/// Hash a clip and its source as heard (`kind` Audio) or seen (`kind` Video): a nested sequence +/// contributes only the tracks of that kind, so editing its picture keeps its sound's previews. +fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, kind: TrackKind, depth: u32) { let mut c = it.clone(); c.id = Default::default(); c.start -= origin; @@ -312,10 +314,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, de c.name.clear(); } h.json(&c); - hash_source(h, project, it.item, depth); + hash_source(h, project, it.item, kind, depth); } -fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) { +fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) { let Some(pi) = project.item(id) else { h.json(&"missing"); return; @@ -338,18 +340,34 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) { } ItemKind::Subclip { parent, range, .. } => { h.json(&("subclip", range)); - hash_source(h, project, *parent, depth + 1); + hash_source(h, project, *parent, kind, depth + 1); } ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)), ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)), ItemKind::Sequence(s) => { h.json(&("sequence", &s.settings)); + if kind == TrackKind::Audio { + // the nest's sound is the nested sequence's whole mix (see `audio_segments`) + h.json(&("mix", s.master_volume_db, &s.master_effects, &s.master_mixer, &s.submix_tracks, &s.multicam)); + for (ti, tr) in s.audio_tracks.iter().enumerate() { + h.json(&("track", ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer, tr.items.len())); + for it in &tr.items { + hash_item(h, project, it, Tick::ZERO, kind, depth + 1); + } + for x in &tr.transitions { + let mut xc = x.clone(); + xc.id = Default::default(); + h.json(&xc); + } + } + return; + } // a nested sequence's captions are drawn into its picture h.json(&("captions", &s.caption_tracks)); for (ti, tr) in s.video_tracks.iter().enumerate() { h.json(&("track", ti, tr.enabled, tr.items.len())); for it in &tr.items { - hash_item(h, project, it, Tick::ZERO, depth + 1); + hash_item(h, project, it, Tick::ZERO, kind, depth + 1); } for x in &tr.transitions { let mut xc = x.clone(); diff --git a/crates/render/src/preview_tests.rs b/crates/render/src/preview_tests.rs index d13d5b349..440bd10c0 100644 --- a/crates/render/src/preview_tests.rs +++ b/crates/render/src/preview_tests.rs @@ -5,7 +5,7 @@ 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, effect_cost_ms, segment_at, video_segments}; +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(); @@ -248,3 +248,54 @@ fn effect_costs() { 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 { + 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); +}