Merge #2287: fix(interchange): OTIO export stops writing nested-sequence copies past a limit

# Conflicts:
#	crates/interchange/README.md
This commit is contained in:
aiongg
2026-10-25 12:34:09 +00:00
2 changed files with 83 additions and 1 deletions
+4
View File
@@ -85,6 +85,10 @@ OMF carries no video at all.
## Tests
- `src/otio.rs` (unit tests): OTIO repeats a nested sequence for every use, so compound clips that each hold
several uses of the next are exponential (a 3 KB FCPXML nested 12 deep made the export run for minutes and grow to
gigabytes). After 25 000 clips written inside nested sequences the rest are gaps, with one warning; an ordinary
project is far below that and unchanged.
- `tests/it/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps,
two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed
gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio
+79 -1
View File
@@ -113,17 +113,27 @@ fn color_label(s: &str) -> Label {
// Export
// ---------------------------------------------------------------------------------------------
/// Most clips an export writes inside nested sequences. OTIO has no references: every use of a nested
/// sequence is written in full, so a compound clip that holds three uses of another, nested ten deep,
/// is 3^10 copies, and a 3 KB document made the export run for minutes and grow to gigabytes. Each
/// clip is about 10 KB of JSON, so this bounds the extra output near 250 MB; real projects use a
/// few thousand. Past it, further nested sequences are written as gaps, with a warning.
const MAX_NESTED_ITEMS: usize = 25_000;
struct Exp<'a, 'r> {
p: &'a Project,
opts: &'a ExportOptions,
report: &'r mut Report,
stack: Vec<ItemId>,
/// Clips written inside nested sequences so far, and the limit for it (see [`MAX_NESTED_ITEMS`]).
nested_items: usize,
nested_limit: usize,
}
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?;
let rate = seq.settings.frame_rate;
let mut x = Exp { p, opts, report, stack: vec![seq_id] };
let mut x = Exp { p, opts, report, stack: vec![seq_id], nested_items: 0, nested_limit: MAX_NESTED_ITEMS };
let name = opts.name.clone().unwrap_or_else(|| p.item(seq_id).map(|i| i.name.clone()).unwrap_or_default());
let stack = x.stack_json(seq_id, &name, None);
let tl = json!({
@@ -247,6 +257,9 @@ impl Exp<'_, '_> {
}
fn item_json(&mut self, it: &TrackItem, kind: TrackKind, rate: FrameRate) -> Value {
if self.stack.len() > 1 {
self.nested_items = self.nested_items.saturating_add(1);
}
let base = base_item(self.p, it.item);
let mut effects = Vec::new();
if let Some(h) = it.frame_hold {
@@ -269,6 +282,17 @@ impl Exp<'_, '_> {
self.report.warn("recursive nested sequence skipped");
return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}});
}
if self.nested_items >= self.nested_limit {
// once: later nested sequences hit this too, and the message would repeat
if self.nested_items < usize::MAX {
self.nested_items = usize::MAX;
self.report.warn(format!(
"more than {} clips written inside nested sequences: the rest are gaps (OTIO repeats a nested sequence for every use)",
self.nested_limit
));
}
return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}});
}
self.stack.push(base);
let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate)));
self.stack.pop();
@@ -823,4 +847,58 @@ mod tests {
let back = read_rt(&rt(t, FrameRate::FPS_24)).unwrap();
assert!((back - t).abs() <= Tick(1));
}
/// A tree of compound clips: `uses` uses of the next level in each, `levels` media levels deep, leaf clips in the last.
fn nested_tree(levels: usize, uses: usize) -> String {
let mut xml = String::from(
r#"<?xml version="1.0"?><fcpxml version="1.10"><resources><format id="f1" frameDuration="100/2500s" width="1920" height="1080"/>
<asset id="a1" name="c" start="0s" duration="10s" hasVideo="1" format="f1"><media-rep kind="original-media" src="file:///tmp/c.mov"/></asset>"#,
);
for level in 0..levels {
let inner: String = (0..uses)
.map(|i| {
if level + 1 < levels {
format!(r#"<ref-clip ref="m{}" offset="{i}s" duration="1s"/>"#, level + 1)
} else {
format!(r#"<asset-clip ref="a1" offset="{i}s" duration="1s"/>"#)
}
})
.collect();
xml.push_str(&format!(r#"<media id="m{level}" name="L{level}"><sequence format="f1" tcStart="0s"><spine>{inner}</spine></sequence></media>"#));
}
xml.push_str(r#"</resources><library><event name="E"><project name="P"><sequence format="f1" tcStart="0s"><spine><ref-clip ref="m0" offset="0s" duration="1s"/></spine></sequence></project></event></library></fcpxml>"#);
xml
}
/// OTIO repeats a nested sequence for every use, so 10 uses nested 3 deep is 111 copies and a 3 KB
/// document nested 12 deep made the export run for minutes and grow to gigabytes. Past the limit on clips
/// written inside nested sequences the rest are gaps, with one warning. The test lowers the limit so it
/// stays tiny: without the check all 112 stacks are written.
#[test]
fn nested_copies_stop_at_the_limit() {
let (imported, _) = crate::import(nested_tree(3, 10).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds");
let seq = imported.sequences[0];
let opts = ExportOptions::default();
let write = |limit: usize| {
let mut report = Report::default();
let mut x = Exp { p: &imported.project, opts: &opts, report: &mut report, stack: vec![seq], nested_items: 0, nested_limit: limit };
let stacks = x.stack_json(seq, "t", None).to_string().matches("\"Stack.1\"").count();
(stacks, report)
};
let (all, report) = write(usize::MAX - 1);
assert_eq!(all, 112);
assert!(!report.mentions("nested sequences"), "{report}");
let (capped, report) = write(200);
assert!(capped < 60, "{capped} stacks");
assert!(report.mentions("clips written inside nested sequences"), "{report}");
assert_eq!(report.warnings().filter(|w| w.message.contains("inside nested sequences")).map(|w| w.count).sum::<u32>(), 1);
}
/// An ordinary project with nested sequences is far below the real limit and exports untouched.
#[test]
fn ordinary_nesting_is_not_capped() {
let (imported, _) = crate::import(nested_tree(3, 4).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds");
let (_, report) = crate::export(&imported.project, imported.sequences[0], crate::Format::Otio, &ExportOptions::default()).expect("export succeeds");
assert!(!report.mentions("inside nested sequences"), "{report}");
}
}