From 6192214defbe3e11d3b4483c2262d5e670f4cc60 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:45:00 +0200 Subject: [PATCH] fix(previews): a nest's audio preview follows the sound of its sequence (#1370) The content hash of a nested sequence covered only its video tracks, for audio segments too. Changing the sound inside a nest (a clip's gain, a muted track, a new clip) left the outer sequence's rendered audio preview valid, so playback kept the old mix, or silence. Audio segments now hash the nested sequence's mix (audio tracks, their clips recursively, master and submixes) instead of its picture, so editing the picture inside a nest no longer invalidates its sound either. --- crates/render/src/preview.rs | 38 +++++++++++++++------ crates/render/src/preview_tests.rs | 53 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 11 deletions(-) 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); +}