mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge #2287: fix(interchange): OTIO export stops writing nested-sequence copies past a limit
# Conflicts: # crates/interchange/README.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user