Merge pull request #1386 from marcoarnulfo/fix/nested-audio-preview-hash

fix(previews): a nest's audio preview follows the sound of its sequence (#1370)
This commit is contained in:
Brandon Thomas
2026-10-13 00:03:31 -04:00
2 changed files with 80 additions and 11 deletions
+28 -10
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();
+52 -1
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);
}