From bb4c6d0bb20a36e596411c6d0b854d19c31ab662 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 10:08:10 -0400 Subject: [PATCH] Format: repair out-of-range audio stream data on load instead of refusing the project A clip with audio_stream >= MAX_AUDIO_STREAMS is reset to stream 0 and media with more than MAX_AUDIO_STREAMS streams keeps the first ones, each with a warning: one bad index must never lose a whole project. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/format/Cargo.toml | 1 + crates/format/src/lib.rs | 30 +++++++++++++++++++----------- crates/format/tests/fixtures.rs | 17 ++++++++++++----- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e555131b..2512f9b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,6 +1922,7 @@ dependencies = [ "filmcraft-media", "filmcraft-project", "filmcraft-time", + "log", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 5e48f640..f2c1e982 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] filmcraft-project = { workspace = true } filmcraft-media = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d4bd2d17..2c97f20a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -211,19 +211,27 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. -fn validate_loaded(loaded: Loaded) -> Result { - for item in loaded.project.items.values() { - if let filmcraft_project::ItemKind::Media(m) = &item.kind - && m.info.audio_streams.len() > filmcraft_media::MAX_AUDIO_STREAMS - { - return Err(FormatError::Corrupt(format!("media `{}`: too many audio streams", item.name))); - } - if let filmcraft_project::ItemKind::Sequence(sequence) = &item.kind { - for track in &sequence.audio_tracks { - if track.items.iter().any(|clip| clip.audio_stream >= filmcraft_media::MAX_AUDIO_STREAMS) { - return Err(FormatError::Corrupt(format!("sequence `{}`: audio stream index out of range", item.name))); +fn validate_loaded(mut loaded: Loaded) -> Result { + // Out-of-range audio stream data is repaired, not refused: failing the load would lose the + // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a + // clip pointing past that plays stream 0. + let max = filmcraft_media::MAX_AUDIO_STREAMS; + for item in loaded.project.items.values_mut() { + let name = item.name.clone(); + match &mut item.kind { + filmcraft_project::ItemKind::Media(m) if m.info.audio_streams.len() > max => { + log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); + m.info.audio_streams.truncate(max); + } + filmcraft_project::ItemKind::Sequence(sequence) => { + for clip in sequence.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) { + if clip.audio_stream >= max { + log::warn!("sequence `{name}`: clip `{}` uses audio stream {}, reset to 0", clip.name, clip.audio_stream); + clip.audio_stream = 0; + } } } + _ => {} } } for item in loaded.project.sequences() { diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index 644a60e5..7cfe2281 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -64,17 +64,24 @@ fn legacy_audio_field_becomes_primary_stream_and_clip_defaults_to_zero() { assert_eq!(again.project.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams, media.info.audio_streams); } +/// Out-of-range audio stream data is repaired on load, never a reason to refuse the project. #[test] -fn hostile_audio_stream_counts_and_indices_are_rejected() { +fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() { let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; let seq = ItemId(21); - p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = filmcraft_media::MAX_AUDIO_STREAMS; - assert!(matches!(decode(&encode(&p, false)), Err(FormatError::Corrupt(_)))); - p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = 0; + for bad in [filmcraft_media::MAX_AUDIO_STREAMS, usize::MAX] { + p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = bad; + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, 0); + } + p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = filmcraft_media::MAX_AUDIO_STREAMS - 1; + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, filmcraft_media::MAX_AUDIO_STREAMS - 1, "in range: kept"); let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap(); let stream = media.info.audio_streams[0].clone(); media.info.audio_streams.resize(filmcraft_media::MAX_AUDIO_STREAMS + 1, stream); - assert!(matches!(decode(&encode(&p, false)), Err(FormatError::Corrupt(_)))); + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS); } /// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels