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:
parent
701d7c7111
commit
bb4c6d0bb2
|
|
@ -1922,6 +1922,7 @@ dependencies = [
|
|||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue