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.
This commit is contained in:
Marco Arnulfo 2026-10-12 19:45:00 +02:00
parent 71438c627b
commit 6192214def
2 changed files with 80 additions and 11 deletions

View File

@ -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<Segment> {
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<Segment> {
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<AudioSegment> {
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<AudioSegment> {
}
} 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();

View File

@ -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<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);
}