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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 10:08:10 -04:00
parent 701d7c7111
commit bb4c6d0bb2
4 changed files with 33 additions and 16 deletions

1
Cargo.lock generated
View File

@ -1922,6 +1922,7 @@ dependencies = [
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",

View File

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

View File

@ -211,19 +211,27 @@ pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
/// 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<Loaded, FormatError> {
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<Loaded, FormatError> {
// 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() {

View File

@ -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