From 3728fd1ffb868597e4eb8904306d877fc11427c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 07:04:54 -0500 Subject: [PATCH] craft-aaf: use referenced slot rates for source offsets * craft-aaf: use referenced slot rate for source offsets --- crates/aaf/src/aaf/read.rs | 16 ++++++++++++++-- crates/aaf/src/aaf/tests.rs | 21 +++++++++++++++++++++ crates/aaf/src/aaf/write.rs | 2 +- crates/aaf/src/roundtrip_tests.rs | 24 ++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/aaf/src/aaf/read.rs b/crates/aaf/src/aaf/read.rs index 7a2ae32b6..e565f0eaa 100644 --- a/crates/aaf/src/aaf/read.rs +++ b/crates/aaf/src/aaf/read.rs @@ -386,7 +386,6 @@ impl R<'_> { fn clip(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, master_key: Option) -> CItem { let len = units_to_ticks(len_of(c), rate.0, rate.1); - let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1); let name = tagged_values(c, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "Clip Name").map(|(_, v)| v).unwrap_or_default(); let (Some(id), Some(slot)) = (c.mob_id(pid::SOURCE_ID), c.u32(pid::SOURCE_MOB_SLOT_ID)) else { return CItem::Filler(len) }; if id == [0; 32] { @@ -395,6 +394,13 @@ impl R<'_> { if let Some(mob) = self.mobs.get(&id).copied().filter(|m| m.class == cls::COMPOSITION_MOB) { return self.nest_clip(c, NestClip { mob, mob_id: id, slot_id: slot, rate, kind, name }); } + let source_rate = self + .mobs + .get(&id) + .and_then(|mob| mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot))) + .and_then(|s| rate_of(s, pid::EDIT_RATE)) + .unwrap_or(rate); + let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), source_rate.0, source_rate.1); match self.resolve(id, slot, start, kind, master_key, 0) { Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }), None => CItem::Filler(len), @@ -491,9 +497,15 @@ impl R<'_> { if mob.class == cls::MASTER_MOB { let key = master_key.unwrap_or_else(|| hex(&id)); let clip = first_clip(seg)?; - let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1); let next = clip.mob_id(pid::SOURCE_ID)?; let next_slot = clip.u32(pid::SOURCE_MOB_SLOT_ID)?; + let source_rate = self + .mobs + .get(&next) + .and_then(|m| m.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(next_slot))) + .and_then(|s| rate_of(s, pid::EDIT_RATE)) + .unwrap_or(rate); + let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), source_rate.0, source_rate.1); if next == [0; 32] { return None; } diff --git a/crates/aaf/src/aaf/tests.rs b/crates/aaf/src/aaf/tests.rs index a0468492b..315f84250 100644 --- a/crates/aaf/src/aaf/tests.rs +++ b/crates/aaf/src/aaf/tests.rs @@ -473,6 +473,27 @@ fn audio_tracks_keep_their_names() { } } +#[test] +fn sound_source_offsets_use_audio_sample_rate_on_disk() { + let doc = crate::testdoc::sound_only(None); + let bytes = written(&doc, false); + let root = store::read(&bytes).unwrap(); + let mut all = Vec::new(); + walk(&root, &mut all); + let starts: Vec = all.iter().filter(|o| o.class == cls::SOURCE_CLIP).filter_map(|o| o.i64(pid::START_TIME)).collect(); + assert!(starts.contains(&19_200), "expected 0.4 seconds at 48 kHz: {starts:?}"); + let back = read_clean(&bytes); + let clip = back.compositions[0].tracks[0] + .items + .iter() + .find_map(|i| match i { + CItem::Clip(c) => Some(c), + _ => None, + }) + .unwrap(); + assert_eq!(clip.start, craft_timeline::time::FrameRate::FPS_25.tick_of(10)); +} + #[test] fn flattened_selectors_and_nested_scopes_are_reported() { for (class, property, message) in diff --git a/crates/aaf/src/aaf/write.rs b/crates/aaf/src/aaf/write.rs index 170b8e621..8c9aae88e 100644 --- a/crates/aaf/src/aaf/write.rs +++ b/crates/aaf/src/aaf/write.rs @@ -360,7 +360,7 @@ pub(crate) fn write(doc: &Document, version: craft_cfb::Version, identity: &Iden // ---- nested compositions: a clip refers to one as it does to a master mob, through the // composition's first track of the clip's kind (a broken-out channel: that channel's track) - let mut refs = Refs { mobs: master_ref, rates: HashMap::new() }; + let mut refs = Refs { mobs: master_ref, rates: doc.sources.iter().enumerate().map(|(i, s)| (i, source_rate(s))).collect() }; let nested_ids: Vec<[u8; 32]> = doc.nested.iter().map(|_| ids.next()).collect(); for (si, s) in doc.sources.iter().enumerate() { let Some((comp, id)) = s.nested.and_then(|n| Some((doc.nested.get(n)?, nested_ids.get(n)?))) else { continue }; diff --git a/crates/aaf/src/roundtrip_tests.rs b/crates/aaf/src/roundtrip_tests.rs index a949eef84..c45985f59 100644 --- a/crates/aaf/src/roundtrip_tests.rs +++ b/crates/aaf/src/roundtrip_tests.rs @@ -217,3 +217,27 @@ fn a_composition_nested_in_itself_is_a_gap() { assert_eq!(back.nested.len(), 1); assert!(matches!(back.nested[0].tracks[0].items[..], [CItem::Filler(_)]), "{:?}", back.nested[0].tracks[0].items); } + +#[test] +fn aaf_source_start_uses_referenced_slot_rate() { + let doc = sound_only(None); + let bytes = aaf_bytes(&doc, false); + let back = read_clean(&bytes, true); + let original = doc.compositions[0].tracks[0] + .items + .iter() + .find_map(|item| match item { + CItem::Clip(c) => Some(c.start), + _ => None, + }) + .unwrap(); + let actual = back.compositions[0].tracks[0] + .items + .iter() + .find_map(|item| match item { + CItem::Clip(c) => Some(c.start), + _ => None, + }) + .unwrap(); + assert_eq!(actual, original); +}