diff --git a/Cargo.lock b/Cargo.lock
index 53aba969..cb3a8582 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1655,7 +1655,7 @@ dependencies = [
[[package]]
name = "filmcraft"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"chrono",
"cpal",
@@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1694,14 +1694,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1717,7 +1717,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1728,14 +1728,14 @@ dependencies = [
[[package]]
name = "filmcraft-bitstream"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1746,9 +1746,13 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "filmcraft-cfb"
+version = "0.1.1"
+
[[package]]
name = "filmcraft-cli"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1764,7 +1768,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1796,7 +1800,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"proptest",
"serde",
@@ -1804,7 +1808,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1814,7 +1818,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1826,7 +1830,7 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -1839,6 +1843,7 @@ dependencies = [
"filmcraft-geom",
"filmcraft-interchange",
"filmcraft-media",
+ "filmcraft-mxf",
"filmcraft-project",
"filmcraft-render",
"filmcraft-scopes",
@@ -1856,7 +1861,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-aac",
"filmcraft-audio-dsp",
@@ -1868,6 +1873,7 @@ dependencies = [
"filmcraft-h264enc",
"filmcraft-isobmff",
"filmcraft-media",
+ "filmcraft-mxf",
"filmcraft-project",
"filmcraft-prores",
"filmcraft-render",
@@ -1884,7 +1890,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1896,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1906,7 +1912,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"proptest",
"serde",
@@ -1914,7 +1920,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1931,7 +1937,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1948,7 +1954,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1958,7 +1964,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1968,7 +1974,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1978,8 +1984,9 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
+ "filmcraft-cfb",
"filmcraft-color",
"filmcraft-geom",
"filmcraft-media",
@@ -1994,7 +2001,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2005,7 +2012,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2013,7 +2020,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2026,7 +2033,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2035,7 +2042,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2043,28 +2050,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-project"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2078,7 +2085,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2088,7 +2095,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2108,7 +2115,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"serde",
@@ -2117,7 +2124,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -2135,14 +2142,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-color",
"harfrust",
@@ -2153,7 +2160,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"proptest",
"serde",
@@ -2161,7 +2168,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -2174,6 +2181,7 @@ dependencies = [
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-gpu",
+ "filmcraft-interchange",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
@@ -2192,7 +2200,7 @@ dependencies = [
[[package]]
name = "filmcraft-vp9"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2201,7 +2209,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -7132,7 +7140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"filmcraft-testkit",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 02dfdf88..3bff887e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.1.0"
+version = "0.1.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -19,6 +19,7 @@ filmcraft-bitstream = { path = "crates/bitstream" }
filmcraft-isobmff = { path = "crates/isobmff" }
filmcraft-matroska = { path = "crates/matroska" }
filmcraft-mxf = { path = "crates/mxf" }
+filmcraft-cfb = { path = "crates/cfb" }
filmcraft-mpegts = { path = "crates/mpegts" }
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
filmcraft-ac3 = { path = "crates/ac3" }
diff --git a/README.md b/README.md
index 2f1b9679..e3a965a0 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
Export mode, set to write the trailer as H.264 MP4.
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off.
-- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, PNG sequences, animated GIF and WAV.
+- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Background jobs** with progress and cancel, so you keep editing while it renders.
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
@@ -211,6 +211,8 @@ Move timelines between FilmCraft and every other editor:
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
+- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
+- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
diff --git a/ROADMAP.md b/ROADMAP.md
index 7e2884b6..76fc7d4b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
-**Last updated:** 2026-10-02 · **Overall parity:** ~86% (measured scorecard below) · **Code:** 34 crates, 1562 tests
+**Last updated:** 2026-10-02 · **Overall parity:** ~87% (measured scorecard below) · **Code:** 34 crates, 1605 tests
## Parity scorecard
@@ -20,10 +20,10 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
-| Export | 8% | own H.264/AAC, ProRes, DNxHR, image sequences, GIF, WAV; preset library + user presets, full Export-mode settings (loudness normalization, overlays, captions), export queue, Quick Export; no AAF/OMF, MXF export | ~80% |
+| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; 8K and AV1 not yet real-time on a loaded machine | ~62% |
-| **Weighted total** | | | **~86%** |
+| **Weighted total** | | | **~87%** |
## Estimate to parity
@@ -77,6 +77,8 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
## Log
+- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
+
- **2026-10-04:** MPEG-2 (M9.15): own MPEG-2/MPEG-1 video decoder (4:2:2, interlaced, field pictures; IEEE 1180 IDCT), MPEG TS/PS demux (AVCHD .mts, .m2ts, .ts, .mpg/.vob/.mod), own AC-3 decoder (ATSC A/52), MP2, LATM AAC, Blu-ray/DVD LPCM, MPEG-2 in MXF/MOV/MP4/MKV. 1562 tests.
- **2026-10-03 (evening):** Project panel and Media Browser (M12.7): Freeform view (stacks, arrangements), Icon view hover scrub with In/Out, list columns from all metadata, ten view presets, multiple Project panels; Media Browser with Favorites/drives/recent, back/forward, filters, thumbnails, Edit Columns, ingest, drag to timeline; all as `project.*` / `mediaBrowser.*` commands. 1513 tests.
diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs
index 3ac1e7c5..36dcd7ba 100644
--- a/apps/filmcraft-cli/src/main.rs
+++ b/apps/filmcraft-cli/src/main.rs
@@ -27,7 +27,7 @@ SUBCOMMANDS
export [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or
- a format (h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff, guessed
+ a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
from the extension); --range entire|inOut|workArea, or a custom
range in seconds; --settings is ExportSettings JSON merged over the
preset; --queue adds to the export queue and runs it instead
@@ -136,7 +136,7 @@ fn format_for(path: &str) -> Option<&'static str> {
Some(match ext.as_str() {
"mp4" | "m4v" => "h264",
"mov" => "prores",
- "mxf" => "dnxhr",
+ "mxf" => "mxf-op1a",
"png" => "png",
"gif" => "gif",
"wav" => "wav",
@@ -366,3 +366,13 @@ async fn main() {
other => usage(format!("unknown subcommand `{other}`")),
}
}
+
+#[cfg(test)]
+mod format_tests {
+ #[test]
+ fn export_format_from_extension() {
+ assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
+ assert_eq!(super::format_for("a.mov"), Some("prores"));
+ assert_eq!(super::format_for("noext"), None);
+ }
+}
diff --git a/crates/cfb/Cargo.toml b/crates/cfb/Cargo.toml
new file mode 100644
index 00000000..9b5185ca
--- /dev/null
+++ b/crates/cfb/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "filmcraft-cfb"
+description = "Clean-room Compound File Binary (Microsoft structured storage, [MS-CFB]) reader and writer: the container of AAF files"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
diff --git a/crates/cfb/README.md b/crates/cfb/README.md
new file mode 100644
index 00000000..b0f0d874
--- /dev/null
+++ b/crates/cfb/README.md
@@ -0,0 +1,52 @@
+# filmcraft-cfb
+
+A clean-room reader and writer for the Compound File Binary format (Microsoft "structured
+storage"), the low-level container of AAF files. Layer L0: no dependencies beyond `std`, no
+`unsafe`, builds for `wasm32-unknown-unknown`. No third-party implementation (the AAF SDK's
+structured storage code, libgsf, olefile, POI…) was consulted.
+
+## Specification
+
+| Document | Edition | Used for |
+|---|---|---|
+| [MS-CFB] Compound File Binary File Format (Microsoft Open Specifications) | revision 10.0 (the format is unchanged since 1.0) | header, sector chains, FAT / DIFAT / mini FAT, directory entries, red-black sibling trees, name comparison, version 3 and 4 files |
+
+## Reading
+
+```rust
+let bytes = std::fs::read("sequence.aaf")?;
+let cf = filmcraft_cfb::CompoundFile::open(&bytes)?;
+let header = cf.find("Header-2")?; // a storage
+println!("{:02x?}", cf.entries[header].clsid); // its class id
+let props = cf.read_path("Header-2/properties")?; // a stream
+for &child in &cf.entries[header].children { /* sorted siblings */ }
+```
+
+- Accepts version 3 (512-byte sectors) and version 4 (4096-byte sectors) files; other sector
+ shifts between 7 and 16 are tolerated.
+- Every chain (DIFAT, FAT, mini FAT, directory, streams) is bounds-checked and cycle-checked; a
+ directory tree that references an entry twice is rejected. Damaged or truncated files return
+ `Error::Invalid`, never panic or loop.
+- Version 3 stream sizes ignore the high 32 bits (§2.6.3).
+
+## Writing
+
+```rust
+let mut w = filmcraft_cfb::Writer::new(filmcraft_cfb::Version::V4);
+w.set_clsid(filmcraft_cfb::Writer::ROOT, root_class);
+let h = w.storage(filmcraft_cfb::Writer::ROOT, "Header-2", header_class)?;
+w.stream(h, "properties", bytes)?;
+let file: Vec = w.finish();
+```
+
+Layout: stream sectors (streams ≥ 4096 bytes), mini stream, mini FAT, directory, FAT, DIFAT. Each
+chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB] name order
+(length first, then upper-cased code units) coloured as valid red-black trees. Names are checked
+(1–31 UTF-16 code units, none of `/ \ : !`, unique per storage, case-insensitively).
+
+## Tests
+
+`src/tests.rs`: round trips for both versions with empty, mini, boundary (63/64/65, 4095/4096/4097
+bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
+a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
+29 bytes and 1500 random mutations never panic and only return complete streams.
diff --git a/crates/cfb/src/lib.rs b/crates/cfb/src/lib.rs
new file mode 100644
index 00000000..f54a2f10
--- /dev/null
+++ b/crates/cfb/src/lib.rs
@@ -0,0 +1,118 @@
+//! Clean-room Compound File Binary format reader and writer ("structured storage", the container
+//! of AAF files), written from the public Microsoft Open Specification **[MS-CFB]: Compound File
+//! Binary File Format** (protocol revision 10.0 and earlier editions; the on-disk format has not
+//! changed since 1.0).
+//!
+//! A compound file is a small FAT file system inside one file: fixed-size sectors (512 bytes in
+//! version 3, 4096 bytes in version 4) chained by a file allocation table, a directory of 128-byte
+//! entries arranged as one red-black tree of siblings per storage, and a "mini stream" with a mini
+//! FAT for streams smaller than 4096 bytes.
+//!
+//! - [`CompoundFile::open`] parses a file held in memory (no copies of large streams; every chain
+//! is bounds- and cycle-checked, so damaged files give errors, never panics or endless loops).
+//! - [`Writer`] builds a tree of storages and streams and lays it out in one pass
+//! ([`Writer::finish`]): stream sectors, mini stream, mini FAT, directory, FAT and DIFAT.
+//!
+//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
+
+mod read;
+mod write;
+
+pub use read::{CompoundFile, Entry, EntryKind};
+pub use write::{NodeId, Version, Writer};
+
+use std::cmp::Ordering;
+use std::fmt;
+
+/// The 8-byte header signature (§2.2).
+pub const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
+/// Largest regular sector number (§2.1).
+pub const MAXREGSECT: u32 = 0xFFFF_FFFA;
+/// Sector used by the DIFAT.
+pub const DIFSECT: u32 = 0xFFFF_FFFC;
+/// Sector used by the FAT.
+pub const FATSECT: u32 = 0xFFFF_FFFD;
+/// End of a sector chain.
+pub const ENDOFCHAIN: u32 = 0xFFFF_FFFE;
+/// Unallocated sector.
+pub const FREESECT: u32 = 0xFFFF_FFFF;
+/// No sibling / child directory entry.
+pub const NOSTREAM: u32 = 0xFFFF_FFFF;
+/// Streams shorter than this live in the mini stream (§2.2 `Mini Stream Cutoff Size`).
+pub const MINI_STREAM_CUTOFF: u32 = 4096;
+/// Mini sector size (§2.2: mini sector shift 6).
+pub const MINI_SECTOR_SIZE: usize = 64;
+/// Longest directory entry name in UTF-16 code units, without the terminating NUL (§2.6.1).
+pub const MAX_NAME_LEN: usize = 31;
+
+/// Errors from the reader and writer.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Error {
+ /// Not a compound file (bad signature or byte order mark).
+ NotCfb,
+ /// Structurally invalid or truncated.
+ Invalid(String),
+ /// No entry with that name / path.
+ NotFound(String),
+ /// A name the format cannot store (too long, illegal characters, duplicate).
+ BadName(String),
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::NotCfb => f.write_str("not a compound file"),
+ Error::Invalid(s) => write!(f, "invalid compound file: {s}"),
+ Error::NotFound(s) => write!(f, "no entry {s:?}"),
+ Error::BadName(s) => write!(f, "invalid entry name {s:?}"),
+ }
+ }
+}
+
+impl std::error::Error for Error {}
+
+pub type Result = std::result::Result;
+
+/// Whether `head` starts with the compound file signature.
+pub fn sniff(head: &[u8]) -> bool {
+ head.len() >= 8 && head[..8] == SIGNATURE
+}
+
+fn upper(u: u16) -> u16 {
+ if (b'a' as u16..=b'z' as u16).contains(&u) {
+ return u - 32;
+ }
+ if u < 0x80 {
+ return u;
+ }
+ match char::from_u32(u as u32) {
+ Some(c) => {
+ let mut up = c.to_uppercase();
+ match (up.next(), up.next()) {
+ (Some(x), None) if (x as u32) <= 0xFFFF => x as u32 as u16,
+ _ => u,
+ }
+ }
+ None => u,
+ }
+}
+
+/// The sibling order of directory entries (§2.6.4): shorter names first, then a code-unit
+/// comparison of the upper-cased names.
+pub fn compare_names(a: &str, b: &str) -> Ordering {
+ let ua: Vec = a.encode_utf16().collect();
+ let ub: Vec = b.encode_utf16().collect();
+ ua.len().cmp(&ub.len()).then_with(|| ua.iter().map(|&u| upper(u)).cmp(ub.iter().map(|&u| upper(u))))
+}
+
+/// Check a directory entry name (§2.6.1): 1–31 UTF-16 code units, none of `/ \ : !`.
+pub fn validate_name(name: &str) -> Result<()> {
+ let n = name.encode_utf16().count();
+ if n == 0 || n > MAX_NAME_LEN || name.contains(['/', '\\', ':', '!']) {
+ return Err(Error::BadName(name.to_string()));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/cfb/src/read.rs b/crates/cfb/src/read.rs
new file mode 100644
index 00000000..32cea2d0
--- /dev/null
+++ b/crates/cfb/src/read.rs
@@ -0,0 +1,333 @@
+//! Reading compound files ([MS-CFB] §2.2–2.6).
+
+use crate::{ENDOFCHAIN, Error, FREESECT, MAXREGSECT, MINI_SECTOR_SIZE, NOSTREAM, Result, SIGNATURE, compare_names};
+
+/// Directory entry object type (§2.6.1).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EntryKind {
+ Root,
+ Storage,
+ Stream,
+ /// Unallocated (type 0) or an unknown type.
+ Unknown,
+}
+
+/// One directory entry.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Entry {
+ pub name: String,
+ pub kind: EntryKind,
+ /// Class id of a storage (AAF: the class of the object stored in it).
+ pub clsid: [u8; 16],
+ pub state_bits: u32,
+ /// FILETIME stamps (0 when not set).
+ pub created: u64,
+ pub modified: u64,
+ /// First sector (regular or mini) of a stream; the mini stream's for the root.
+ pub start: u32,
+ pub size: u64,
+ /// Child entries of a storage, in sibling-tree order (sorted by [`compare_names`]).
+ pub children: Vec,
+ left: u32,
+ right: u32,
+ child: u32,
+}
+
+/// A parsed compound file over borrowed bytes.
+#[derive(Clone, Debug)]
+pub struct CompoundFile<'a> {
+ data: &'a [u8],
+ /// Major version: 3 (512-byte sectors) or 4 (4096-byte sectors).
+ pub version: u16,
+ /// Header CLSID (normally zero).
+ pub header_clsid: [u8; 16],
+ sector_size: usize,
+ mini_cutoff: u64,
+ fat: Vec,
+ minifat: Vec,
+ mini_stream: Vec,
+ pub entries: Vec,
+}
+
+fn u16_at(b: &[u8], at: usize) -> u16 {
+ u16::from_le_bytes([b[at], b[at + 1]])
+}
+fn u32_at(b: &[u8], at: usize) -> u32 {
+ u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
+}
+fn u64_at(b: &[u8], at: usize) -> u64 {
+ u64::from_le_bytes(b[at..at + 8].try_into().unwrap_or([0; 8]))
+}
+
+fn invalid(s: impl Into) -> Error {
+ Error::Invalid(s.into())
+}
+
+impl<'a> CompoundFile<'a> {
+ /// Parse the header, FAT, mini FAT, directory and mini stream of `data`.
+ pub fn open(data: &'a [u8]) -> Result> {
+ if data.len() < 512 || data[..8] != SIGNATURE || u16_at(data, 28) != 0xFFFE {
+ return Err(Error::NotCfb);
+ }
+ let version = u16_at(data, 26);
+ let shift = u16_at(data, 30);
+ if !(7..=16).contains(&shift) {
+ return Err(invalid(format!("sector shift {shift}")));
+ }
+ let mini_shift = u16_at(data, 32);
+ if 1usize.checked_shl(mini_shift as u32) != Some(MINI_SECTOR_SIZE) {
+ return Err(invalid(format!("mini sector shift {mini_shift}")));
+ }
+ let sector_size = 1usize << shift;
+ let n_fat = u32_at(data, 44) as usize;
+ let first_dir = u32_at(data, 48);
+ let mini_cutoff = u32_at(data, 56) as u64;
+ let first_minifat = u32_at(data, 60);
+ let first_difat = u32_at(data, 68);
+ let n_difat = u32_at(data, 72) as usize;
+ let mut header_clsid = [0u8; 16];
+ header_clsid.copy_from_slice(&data[8..24]);
+ let max_sectors = data.len() / sector_size + 1;
+ if n_fat > max_sectors {
+ return Err(invalid(format!("{n_fat} FAT sectors in a {}-byte file", data.len())));
+ }
+ let mut cf = CompoundFile {
+ data,
+ version,
+ header_clsid,
+ sector_size,
+ mini_cutoff,
+ fat: Vec::new(),
+ minifat: Vec::new(),
+ mini_stream: Vec::new(),
+ entries: Vec::new(),
+ };
+ // FAT sector numbers: 109 in the header, then the DIFAT chain.
+ let mut fat_sectors: Vec = (0..109).map(|i| u32_at(data, 76 + i * 4)).filter(|&s| s <= MAXREGSECT).collect();
+ let mut d = first_difat;
+ let mut seen = 0;
+ while d <= MAXREGSECT && fat_sectors.len() < n_fat {
+ seen += 1;
+ if seen > n_difat.max(1) + max_sectors {
+ return Err(invalid("DIFAT chain loops"));
+ }
+ let s = cf.sector(d)?;
+ let per = sector_size / 4 - 1;
+ for i in 0..per {
+ let v = u32_at(s, i * 4);
+ if v <= MAXREGSECT {
+ fat_sectors.push(v);
+ }
+ }
+ d = u32_at(s, per * 4);
+ }
+ fat_sectors.truncate(n_fat);
+ let mut fat = Vec::with_capacity(fat_sectors.len() * sector_size / 4);
+ for s in fat_sectors {
+ let b = cf.sector(s)?;
+ fat.extend((0..sector_size / 4).map(|i| u32_at(b, i * 4)));
+ }
+ cf.fat = fat;
+ // Directory.
+ let dir = cf.read_chain(first_dir, None)?;
+ let n_entries = dir.len() / 128;
+ if n_entries == 0 {
+ return Err(invalid("empty directory"));
+ }
+ for i in 0..n_entries {
+ cf.entries.push(parse_entry(&dir[i * 128..(i + 1) * 128], version));
+ }
+ if cf.entries[0].kind != EntryKind::Root {
+ return Err(invalid("first directory entry is not the root"));
+ }
+ // Mini FAT and mini stream.
+ if first_minifat <= MAXREGSECT {
+ let mf = cf.read_chain(first_minifat, None)?;
+ cf.minifat = mf.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
+ }
+ let root = &cf.entries[0];
+ if root.start <= MAXREGSECT && root.size > 0 {
+ let size = root.size.min(data.len() as u64) as usize;
+ cf.mini_stream = cf.read_chain(root.start, Some(size))?;
+ }
+ cf.build_tree()?;
+ Ok(cf)
+ }
+
+ /// Sector `n` (a whole sector; a truncated last sector is an error).
+ fn sector(&self, n: u32) -> Result<&'a [u8]> {
+ let start = (n as usize + 1).checked_mul(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
+ let end = start.checked_add(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
+ if n > MAXREGSECT || end > self.data.len() {
+ return Err(invalid(format!("sector {n} is outside the file")));
+ }
+ Ok(&self.data[start..end])
+ }
+
+ /// Concatenate a regular sector chain; `limit` stops once that many bytes are read.
+ fn read_chain(&self, start: u32, limit: Option) -> Result> {
+ let mut out = Vec::new();
+ let mut s = start;
+ let mut steps = 0usize;
+ while s != ENDOFCHAIN {
+ if s > MAXREGSECT {
+ return Err(invalid(format!("bad sector {s:#x} in a chain")));
+ }
+ steps += 1;
+ if steps > self.fat.len().max(1) {
+ return Err(invalid("sector chain loops"));
+ }
+ let b = self.sector(s)?;
+ out.extend_from_slice(b);
+ if limit.is_some_and(|l| out.len() >= l) {
+ break;
+ }
+ s = *self.fat.get(s as usize).ok_or_else(|| invalid(format!("sector {s} has no FAT entry")))?;
+ if s == FREESECT {
+ return Err(invalid("chain runs into a free sector"));
+ }
+ }
+ if let Some(l) = limit {
+ if out.len() < l {
+ return Err(invalid("stream is truncated"));
+ }
+ out.truncate(l);
+ }
+ Ok(out)
+ }
+
+ fn read_mini_chain(&self, start: u32, size: usize) -> Result> {
+ let mut out = Vec::with_capacity(size);
+ let mut s = start;
+ let mut steps = 0usize;
+ while out.len() < size {
+ if s > MAXREGSECT {
+ return Err(invalid("mini stream chain ends early"));
+ }
+ steps += 1;
+ if steps > self.minifat.len().max(1) {
+ return Err(invalid("mini sector chain loops"));
+ }
+ let at = s as usize * MINI_SECTOR_SIZE;
+ let b = self.mini_stream.get(at..at + MINI_SECTOR_SIZE).ok_or_else(|| invalid("mini sector outside the mini stream"))?;
+ out.extend_from_slice(b);
+ s = *self.minifat.get(s as usize).ok_or_else(|| invalid("mini sector has no mini FAT entry"))?;
+ }
+ out.truncate(size);
+ Ok(out)
+ }
+
+ fn build_tree(&mut self) -> Result<()> {
+ let n = self.entries.len();
+ let mut owner = vec![false; n];
+ owner[0] = true;
+ for parent in 0..n {
+ if !matches!(self.entries[parent].kind, EntryKind::Root | EntryKind::Storage) {
+ continue;
+ }
+ let mut kids = Vec::new();
+ // in-order walk of the sibling tree, iterative, cycle-safe
+ let mut stack: Vec = Vec::new();
+ let mut cur = self.entries[parent].child;
+ let mut steps = 0;
+ while cur != NOSTREAM || !stack.is_empty() {
+ while cur != NOSTREAM {
+ let i = cur as usize;
+ if i >= n || owner[i] {
+ return Err(invalid(format!("directory entry {cur} is referenced twice or does not exist")));
+ }
+ owner[i] = true;
+ stack.push(cur);
+ cur = self.entries[i].left;
+ steps += 1;
+ if steps > n {
+ return Err(invalid("directory tree loops"));
+ }
+ }
+ if let Some(top) = stack.pop() {
+ kids.push(top as usize);
+ cur = self.entries[top as usize].right;
+ }
+ }
+ self.entries[parent].children = kids;
+ }
+ Ok(())
+ }
+
+ pub fn root(&self) -> &Entry {
+ &self.entries[0]
+ }
+
+ pub fn entry(&self, id: usize) -> Option<&Entry> {
+ self.entries.get(id)
+ }
+
+ /// Child of storage `parent` named `name` (case-insensitive as in §2.6.4).
+ pub fn child(&self, parent: usize, name: &str) -> Option {
+ self.entries.get(parent)?.children.iter().copied().find(|&c| compare_names(&self.entries[c].name, name).is_eq())
+ }
+
+ /// Entry at a `/`-separated path below the root (`""` is the root).
+ pub fn find(&self, path: &str) -> Result {
+ let mut cur = 0;
+ for part in path.split('/').filter(|p| !p.is_empty()) {
+ cur = self.child(cur, part).ok_or_else(|| Error::NotFound(path.to_string()))?;
+ }
+ Ok(cur)
+ }
+
+ /// Contents of stream `id`.
+ pub fn read(&self, id: usize) -> Result> {
+ let e = self.entries.get(id).ok_or_else(|| Error::NotFound(format!("entry {id}")))?;
+ if e.kind != EntryKind::Stream {
+ return Err(invalid(format!("{:?} is not a stream", e.name)));
+ }
+ let size = usize::try_from(e.size).map_err(|_| invalid("stream too large"))?;
+ if size == 0 {
+ return Ok(Vec::new());
+ }
+ if size as u64 > self.data.len() as u64 {
+ return Err(invalid(format!("stream {:?} is larger than the file", e.name)));
+ }
+ if e.size < self.mini_cutoff { self.read_mini_chain(e.start, size) } else { self.read_chain(e.start, Some(size)) }
+ }
+
+ /// Contents of the stream at `path`.
+ pub fn read_path(&self, path: &str) -> Result> {
+ self.read(self.find(path)?)
+ }
+
+ /// Sector size in bytes (512 or 4096).
+ pub fn sector_size(&self) -> usize {
+ self.sector_size
+ }
+}
+
+fn parse_entry(b: &[u8], version: u16) -> Entry {
+ let name_len = (u16_at(b, 64) as usize).min(64);
+ let units: Vec = (0..name_len / 2).map(|i| u16_at(b, i * 2)).take_while(|&u| u != 0).collect();
+ let kind = match b[66] {
+ 5 => EntryKind::Root,
+ 1 => EntryKind::Storage,
+ 2 => EntryKind::Stream,
+ _ => EntryKind::Unknown,
+ };
+ let mut clsid = [0u8; 16];
+ clsid.copy_from_slice(&b[80..96]);
+ let size = u64_at(b, 120);
+ Entry {
+ name: String::from_utf16_lossy(&units),
+ kind,
+ clsid,
+ state_bits: u32_at(b, 96),
+ created: u64_at(b, 100),
+ modified: u64_at(b, 108),
+ start: u32_at(b, 116),
+ // version 3 files may leave garbage in the high 32 bits (§2.6.3)
+ size: if version == 3 { size & 0xFFFF_FFFF } else { size },
+ children: Vec::new(),
+ left: u32_at(b, 68),
+ right: u32_at(b, 72),
+ child: u32_at(b, 76),
+ }
+}
diff --git a/crates/cfb/src/tests.rs b/crates/cfb/src/tests.rs
new file mode 100644
index 00000000..cdcb62db
--- /dev/null
+++ b/crates/cfb/src/tests.rs
@@ -0,0 +1,201 @@
+use crate::*;
+
+fn pattern(n: usize, seed: u8) -> Vec {
+ (0..n).map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8).collect()
+}
+
+/// A tree with mini and regular streams, nested storages and many siblings.
+fn sample(version: Version, siblings: usize) -> (Vec, Vec<(String, Vec)>) {
+ let mut w = Writer::new(version);
+ w.set_clsid(Writer::ROOT, [7; 16]);
+ let mut expect = Vec::new();
+ let a = w.storage(Writer::ROOT, "Header-2", [1; 16]).unwrap();
+ let b = w.storage(a, "Content-3b03", [2; 16]).unwrap();
+ for (i, size) in [0usize, 1, 63, 64, 65, 4095, 4096, 4097, 20_000].iter().enumerate() {
+ let name = format!("s{i}");
+ let data = pattern(*size, i as u8);
+ w.stream(b, &name, data.clone()).unwrap();
+ expect.push((format!("Header-2/Content-3b03/{name}"), data));
+ }
+ for i in 0..siblings {
+ let name = format!("Mobs-1901{{{i:x}}}");
+ let s = w.storage(Writer::ROOT, &name, [3; 16]).unwrap();
+ let data = pattern(10 + i * 7, 99);
+ w.stream(s, "properties", data.clone()).unwrap();
+ expect.push((format!("{name}/properties"), data));
+ }
+ (w.finish(), expect)
+}
+
+fn check(bytes: &[u8], expect: &[(String, Vec)]) {
+ let cf = CompoundFile::open(bytes).unwrap();
+ assert_eq!(cf.root().clsid, [7; 16]);
+ for (path, data) in expect {
+ assert_eq!(&cf.read_path(path).unwrap(), data, "{path}");
+ }
+ let h = cf.find("Header-2").unwrap();
+ assert_eq!(cf.entries[h].clsid, [1; 16]);
+ assert_eq!(cf.entries[h].kind, EntryKind::Storage);
+ // children come back sorted in CFB order
+ for e in &cf.entries {
+ for w in e.children.windows(2) {
+ assert!(compare_names(&cf.entries[w[0]].name, &cf.entries[w[1]].name).is_lt());
+ }
+ }
+}
+
+/// Every path from a node to a nil leaf has the same number of black nodes, and no red node has
+/// a red child.
+fn check_red_black(bytes: &[u8]) {
+ let cf = CompoundFile::open(bytes).unwrap();
+ let raw = |i: u32| -> (u32, u32, u8) {
+ let ss = cf.sector_size();
+ let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
+ // the writer stores the directory contiguously
+ let at = (dir_start as usize + 1) * ss + i as usize * 128;
+ let e = &bytes[at..at + 128];
+ (u32::from_le_bytes(e[68..72].try_into().unwrap()), u32::from_le_bytes(e[72..76].try_into().unwrap()), e[67])
+ };
+ fn black_height(raw: &dyn Fn(u32) -> (u32, u32, u8), n: u32, parent_red: bool) -> usize {
+ if n == NOSTREAM {
+ return 1;
+ }
+ let (l, r, c) = raw(n);
+ let red = c == 0;
+ assert!(!(red && parent_red), "red node with a red parent");
+ let hl = black_height(raw, l, red);
+ let hr = black_height(raw, r, red);
+ assert_eq!(hl, hr, "unbalanced black height");
+ hl + usize::from(!red)
+ }
+ let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
+ let ss = cf.sector_size();
+ for i in 0..cf.entries.len() {
+ let at = (dir_start as usize + 1) * ss + i * 128;
+ let child = u32::from_le_bytes(bytes[at + 76..at + 80].try_into().unwrap());
+ if child != NOSTREAM {
+ let (_, _, c) = raw(child);
+ assert_eq!(c, 1, "tree roots are black");
+ black_height(&raw, child, false);
+ }
+ }
+}
+
+#[test]
+fn round_trip_v3_and_v4() {
+ for v in [Version::V3, Version::V4] {
+ for n in [0, 1, 2, 3, 7, 8, 40] {
+ let (bytes, expect) = sample(v, n);
+ assert_eq!(bytes.len() % v_sector(v), 0);
+ check(&bytes, &expect);
+ check_red_black(&bytes);
+ }
+ }
+}
+
+fn v_sector(v: Version) -> usize {
+ if v == Version::V3 { 512 } else { 4096 }
+}
+
+#[test]
+fn header_fields() {
+ let (b, _) = sample(Version::V4, 3);
+ assert!(sniff(&b));
+ assert_eq!(&b[24..26], &0x3Eu16.to_le_bytes());
+ assert_eq!(&b[26..28], &4u16.to_le_bytes());
+ assert_eq!(&b[30..32], &12u16.to_le_bytes());
+ let (b, _) = sample(Version::V3, 3);
+ assert_eq!(&b[26..28], &3u16.to_le_bytes());
+ assert_eq!(&b[30..32], &9u16.to_le_bytes());
+ assert_eq!(&b[40..44], &0u32.to_le_bytes(), "version 3 leaves the directory sector count 0");
+ assert_eq!(CompoundFile::open(&b).unwrap().version, 3);
+}
+
+#[test]
+fn large_file_needs_difat() {
+ // > 109 FAT sectors with 512-byte sectors: 109 * 128 sectors * 512 bytes ≈ 7.1 MB
+ let mut w = Writer::new(Version::V3);
+ let big = pattern(7_500_000, 5);
+ w.stream(Writer::ROOT, "big", big.clone()).unwrap();
+ w.stream(Writer::ROOT, "small", vec![1, 2, 3]).unwrap();
+ let b = w.finish();
+ let n_difat = u32::from_le_bytes(b[72..76].try_into().unwrap());
+ assert!(n_difat >= 1);
+ let cf = CompoundFile::open(&b).unwrap();
+ assert_eq!(cf.read_path("big").unwrap(), big);
+ assert_eq!(cf.read_path("small").unwrap(), vec![1, 2, 3]);
+}
+
+#[test]
+fn names() {
+ assert!(compare_names("b", "aa").is_lt(), "shorter first");
+ assert!(compare_names("abc", "ABD").is_lt());
+ assert!(compare_names("Abc", "aBC").is_eq());
+ assert!(validate_name("a/b").is_err());
+ assert!(validate_name(&"x".repeat(32)).is_err());
+ assert!(validate_name(&"x".repeat(31)).is_ok());
+ let mut w = Writer::default();
+ w.stream(Writer::ROOT, "Name", vec![]).unwrap();
+ assert!(matches!(w.stream(Writer::ROOT, "NAME", vec![]), Err(Error::BadName(_))));
+ let s = w.stream(Writer::ROOT, "x", vec![]).unwrap();
+ assert!(w.storage(s, "y", [0; 16]).is_err(), "streams have no children");
+ let cf_bytes = w.finish();
+ let cf = CompoundFile::open(&cf_bytes).unwrap();
+ assert!(cf.find("name").is_ok(), "lookups are case-insensitive");
+ assert!(matches!(cf.find("nope"), Err(Error::NotFound(_))));
+ assert!(cf.read(0).is_err(), "the root is not a stream");
+}
+
+#[test]
+fn unicode_names() {
+ let mut w = Writer::default();
+ w.stream(Writer::ROOT, "Ünïcødé ストリーム", vec![9; 10]).unwrap();
+ let b = w.finish();
+ let cf = CompoundFile::open(&b).unwrap();
+ assert_eq!(cf.read_path("üNÏCØDÉ ストリーム").unwrap(), vec![9; 10]);
+}
+
+#[test]
+fn not_cfb() {
+ assert_eq!(CompoundFile::open(b"hello").unwrap_err(), Error::NotCfb);
+ assert_eq!(CompoundFile::open(&[0u8; 600]).unwrap_err(), Error::NotCfb);
+}
+
+#[test]
+fn truncation_never_panics() {
+ let (b, expect) = sample(Version::V3, 20);
+ let mut ok = 0;
+ for cut in (0..b.len()).step_by(29) {
+ if let Ok(cf) = CompoundFile::open(&b[..cut]) {
+ ok += 1;
+ for (path, data) in &expect {
+ if let Ok(d) = cf.read_path(path) {
+ assert_eq!(&d, data, "a stream that reads back is complete");
+ }
+ }
+ }
+ }
+ let _ = ok;
+}
+
+#[test]
+fn mutation_never_panics_or_hangs() {
+ let (b, _) = sample(Version::V3, 12);
+ let mut seed = 0x9E37_79B9_7F4A_7C15u64;
+ for _ in 0..1500 {
+ let mut g = b.clone();
+ for _ in 0..6 {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ // bias towards the header, FAT and directory (the end of the file)
+ let at = if seed & 1 == 0 { (seed >> 8) as usize % 512 } else { g.len() - 1 - (seed >> 8) as usize % 2048.min(g.len()) };
+ g[at] = (seed >> 40) as u8;
+ }
+ if let Ok(cf) = CompoundFile::open(&g) {
+ for i in 0..cf.entries.len() {
+ let _ = cf.read(i);
+ }
+ }
+ }
+}
diff --git a/crates/cfb/src/write.rs b/crates/cfb/src/write.rs
new file mode 100644
index 00000000..923e1aa3
--- /dev/null
+++ b/crates/cfb/src/write.rs
@@ -0,0 +1,341 @@
+//! Writing compound files ([MS-CFB] §2.2–2.6).
+//!
+//! Layout written by [`Writer::finish`] (sector numbers ascending): the sectors of every stream of
+//! 4096 bytes or more, the mini stream, the mini FAT, the directory, the FAT, then DIFAT sectors
+//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
+//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
+
+use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
+
+/// File format version.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Version {
+ /// Version 3: 512-byte sectors.
+ V3,
+ /// Version 4: 4096-byte sectors (AAF's "4K" files).
+ #[default]
+ V4,
+}
+
+impl Version {
+ fn sector_size(self) -> usize {
+ match self {
+ Version::V3 => 512,
+ Version::V4 => 4096,
+ }
+ }
+}
+
+/// A storage or stream added to a [`Writer`].
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct NodeId(pub usize);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Kind {
+ Root,
+ Storage,
+ Stream,
+}
+
+#[derive(Clone, Debug)]
+struct Node {
+ name: String,
+ kind: Kind,
+ clsid: [u8; 16],
+ data: Vec,
+ children: Vec,
+}
+
+/// Builds a compound file in memory.
+#[derive(Clone, Debug)]
+pub struct Writer {
+ version: Version,
+ nodes: Vec,
+}
+
+impl Default for Writer {
+ fn default() -> Self {
+ Writer::new(Version::default())
+ }
+}
+
+impl Writer {
+ /// The root storage.
+ pub const ROOT: NodeId = NodeId(0);
+
+ pub fn new(version: Version) -> Self {
+ Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
+ }
+
+ fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec) -> Result {
+ validate_name(name)?;
+ let p = self.nodes.get(parent.0).ok_or_else(|| crate::Error::NotFound(format!("node {}", parent.0)))?;
+ if p.kind == Kind::Stream {
+ return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
+ }
+ if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
+ return Err(crate::Error::BadName(format!("duplicate name {name}")));
+ }
+ let id = self.nodes.len();
+ self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
+ self.nodes[parent.0].children.push(id);
+ Ok(NodeId(id))
+ }
+
+ /// Add a storage below `parent`.
+ pub fn storage(&mut self, parent: NodeId, name: &str, clsid: [u8; 16]) -> Result {
+ self.add(parent, name, Kind::Storage, clsid, Vec::new())
+ }
+
+ /// Add a stream below `parent`.
+ pub fn stream(&mut self, parent: NodeId, name: &str, data: Vec) -> Result {
+ self.add(parent, name, Kind::Stream, [0; 16], data)
+ }
+
+ /// Set the class id of a storage (or the root).
+ pub fn set_clsid(&mut self, node: NodeId, clsid: [u8; 16]) {
+ if let Some(n) = self.nodes.get_mut(node.0) {
+ n.clsid = clsid;
+ }
+ }
+
+ /// Lay the file out and return its bytes.
+ pub fn finish(self) -> Vec {
+ let ss = self.version.sector_size();
+ let per_sector = ss / 4;
+ // Directory order: depth-first pre-order from the root.
+ let mut order = Vec::with_capacity(self.nodes.len());
+ let mut stack = vec![0usize];
+ while let Some(n) = stack.pop() {
+ order.push(n);
+ for &c in self.nodes[n].children.iter().rev() {
+ stack.push(c);
+ }
+ }
+ let mut dir_index = vec![0u32; self.nodes.len()];
+ for (i, &n) in order.iter().enumerate() {
+ dir_index[n] = i as u32;
+ }
+
+ // Stream placement.
+ let mut start = vec![ENDOFCHAIN; self.nodes.len()];
+ let mut next_sector = 0u32;
+ let mut big_chains: Vec<(u32, u32)> = Vec::new(); // (first, count)
+ for &n in &order {
+ let node = &self.nodes[n];
+ if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
+ let count = node.data.len().div_ceil(ss) as u32;
+ start[n] = next_sector;
+ big_chains.push((next_sector, count));
+ next_sector += count;
+ }
+ }
+ let mut next_mini = 0u32;
+ let mut mini_chains: Vec<(u32, u32)> = Vec::new();
+ for &n in &order {
+ let node = &self.nodes[n];
+ if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
+ let count = node.data.len().div_ceil(MINI_SECTOR_SIZE) as u32;
+ start[n] = next_mini;
+ mini_chains.push((next_mini, count));
+ next_mini += count;
+ }
+ }
+ let mini_bytes = next_mini as usize * MINI_SECTOR_SIZE;
+ let mini_stream_sectors = mini_bytes.div_ceil(ss) as u32;
+ let mini_stream_start = if mini_stream_sectors > 0 { next_sector } else { ENDOFCHAIN };
+ next_sector += mini_stream_sectors;
+ let minifat_sectors = (next_mini as usize * 4).div_ceil(ss) as u32;
+ let minifat_start = if minifat_sectors > 0 { next_sector } else { ENDOFCHAIN };
+ next_sector += minifat_sectors;
+ let dir_sectors = (order.len() * 128).div_ceil(ss) as u32;
+ let dir_start = next_sector;
+ next_sector += dir_sectors;
+ let base = next_sector as usize;
+ let (mut fat_n, mut difat_n) = (1usize, 0usize);
+ loop {
+ let total = base + fat_n + difat_n;
+ let need_fat = total.div_ceil(per_sector);
+ let need_difat = if need_fat > 109 { (need_fat - 109).div_ceil(per_sector - 1) } else { 0 };
+ if need_fat == fat_n && need_difat == difat_n {
+ break;
+ }
+ fat_n = need_fat.max(fat_n);
+ difat_n = need_difat.max(difat_n);
+ }
+ let fat_start = next_sector;
+ let difat_start = fat_start + fat_n as u32;
+ let total = base + fat_n + difat_n;
+
+ // FAT.
+ let mut fat = vec![FREESECT; fat_n * per_sector];
+ let chain = |first: u32, count: u32, fat: &mut [u32]| {
+ for i in 0..count {
+ fat[(first + i) as usize] = if i + 1 == count { ENDOFCHAIN } else { first + i + 1 };
+ }
+ };
+ for &(f, c) in &big_chains {
+ chain(f, c, &mut fat);
+ }
+ if mini_stream_sectors > 0 {
+ chain(mini_stream_start, mini_stream_sectors, &mut fat);
+ }
+ if minifat_sectors > 0 {
+ chain(minifat_start, minifat_sectors, &mut fat);
+ }
+ chain(dir_start, dir_sectors, &mut fat);
+ for i in 0..fat_n {
+ fat[fat_start as usize + i] = FATSECT;
+ }
+ for i in 0..difat_n {
+ fat[difat_start as usize + i] = DIFSECT;
+ }
+ debug_assert!(total <= fat.len());
+
+ // Mini FAT.
+ let mut minifat = vec![FREESECT; minifat_sectors as usize * per_sector];
+ for &(f, c) in &mini_chains {
+ for i in 0..c {
+ minifat[(f + i) as usize] = if i + 1 == c { ENDOFCHAIN } else { f + i + 1 };
+ }
+ }
+
+ // Directory.
+ let mut tree = vec![(NOSTREAM, NOSTREAM, NOSTREAM, 1u8); self.nodes.len()]; // left, right, child, colour
+ for (p, node) in self.nodes.iter().enumerate() {
+ if node.kind == Kind::Stream || node.children.is_empty() {
+ continue;
+ }
+ let mut kids = node.children.clone();
+ kids.sort_by(|&a, &b| compare_names(&self.nodes[a].name, &self.nodes[b].name));
+ let height = usize::BITS - (kids.len() + 1).leading_zeros() - 1; // floor(log2(n + 1))
+ let perfect = (1usize << height) - 1 == kids.len();
+ let root = build_tree(&kids, 0, perfect, height as usize, &mut tree, &dir_index);
+ tree[p].2 = root;
+ }
+ let mut dir = Vec::with_capacity(dir_sectors as usize * ss);
+ for &n in &order {
+ let node = &self.nodes[n];
+ let mut e = [0u8; 128];
+ let units: Vec = node.name.encode_utf16().collect();
+ for (i, u) in units.iter().enumerate() {
+ e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
+ }
+ e[64..66].copy_from_slice(&(((units.len() + 1) * 2) as u16).to_le_bytes());
+ e[66] = match node.kind {
+ Kind::Root => 5,
+ Kind::Storage => 1,
+ Kind::Stream => 2,
+ };
+ let (l, r, c, colour) = tree[n];
+ e[67] = if node.kind == Kind::Root { 1 } else { colour };
+ e[68..72].copy_from_slice(&l.to_le_bytes());
+ e[72..76].copy_from_slice(&r.to_le_bytes());
+ e[76..80].copy_from_slice(&c.to_le_bytes());
+ e[80..96].copy_from_slice(&node.clsid);
+ let (st, size) = match node.kind {
+ Kind::Root => (mini_stream_start, mini_bytes as u64),
+ Kind::Storage => (0, 0),
+ Kind::Stream => (if node.data.is_empty() { ENDOFCHAIN } else { start[n] }, node.data.len() as u64),
+ };
+ e[116..120].copy_from_slice(&st.to_le_bytes());
+ e[120..128].copy_from_slice(&size.to_le_bytes());
+ dir.extend_from_slice(&e);
+ }
+ while dir.len() < dir_sectors as usize * ss {
+ let mut e = [0u8; 128];
+ e[68..80].copy_from_slice(&[0xFF; 12]);
+ dir.extend_from_slice(&e);
+ }
+
+ // Header.
+ let mut out = Vec::with_capacity((total + 1) * ss);
+ out.extend_from_slice(&SIGNATURE);
+ out.extend_from_slice(&[0; 16]);
+ out.extend_from_slice(&0x003Eu16.to_le_bytes());
+ out.extend_from_slice(&(if self.version == Version::V3 { 3u16 } else { 4u16 }).to_le_bytes());
+ out.extend_from_slice(&0xFFFEu16.to_le_bytes());
+ out.extend_from_slice(&(if self.version == Version::V3 { 9u16 } else { 12u16 }).to_le_bytes());
+ out.extend_from_slice(&6u16.to_le_bytes());
+ out.extend_from_slice(&[0; 6]);
+ out.extend_from_slice(&(if self.version == Version::V3 { 0 } else { dir_sectors }).to_le_bytes());
+ out.extend_from_slice(&(fat_n as u32).to_le_bytes());
+ out.extend_from_slice(&dir_start.to_le_bytes());
+ out.extend_from_slice(&0u32.to_le_bytes());
+ out.extend_from_slice(&MINI_STREAM_CUTOFF.to_le_bytes());
+ out.extend_from_slice(&minifat_start.to_le_bytes());
+ out.extend_from_slice(&minifat_sectors.to_le_bytes());
+ out.extend_from_slice(&(if difat_n > 0 { difat_start } else { ENDOFCHAIN }).to_le_bytes());
+ out.extend_from_slice(&(difat_n as u32).to_le_bytes());
+ for i in 0..109 {
+ let v = if i < fat_n { fat_start + i as u32 } else { FREESECT };
+ out.extend_from_slice(&v.to_le_bytes());
+ }
+ out.resize(ss, 0);
+
+ // Sectors.
+ let pad = |out: &mut Vec| {
+ let r = out.len() % ss;
+ if r != 0 {
+ out.resize(out.len() + ss - r, 0);
+ }
+ };
+ for &n in &order {
+ let node = &self.nodes[n];
+ if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
+ out.extend_from_slice(&node.data);
+ pad(&mut out);
+ }
+ }
+ for &n in &order {
+ let node = &self.nodes[n];
+ if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
+ out.extend_from_slice(&node.data);
+ let r = node.data.len() % MINI_SECTOR_SIZE;
+ if r != 0 {
+ out.resize(out.len() + MINI_SECTOR_SIZE - r, 0);
+ }
+ }
+ }
+ pad(&mut out);
+ for v in &minifat {
+ out.extend_from_slice(&v.to_le_bytes());
+ }
+ out.extend_from_slice(&dir);
+ for v in &fat {
+ out.extend_from_slice(&v.to_le_bytes());
+ }
+ let mut remaining: Vec = (109..fat_n).map(|i| fat_start + i as u32).collect();
+ for i in 0..difat_n {
+ let mut s = Vec::with_capacity(ss);
+ let take = remaining.len().min(per_sector - 1);
+ for v in remaining.drain(..take) {
+ s.extend_from_slice(&v.to_le_bytes());
+ }
+ while s.len() < ss - 4 {
+ s.extend_from_slice(&FREESECT.to_le_bytes());
+ }
+ let next = if i + 1 < difat_n { difat_start + i as u32 + 1 } else { ENDOFCHAIN };
+ s.extend_from_slice(&next.to_le_bytes());
+ out.extend_from_slice(&s);
+ }
+ debug_assert_eq!(out.len(), (total + 1) * ss);
+ out
+ }
+}
+
+/// Balanced BST of `kids[..]` (sorted). Nodes on the bottom level of an imperfect tree are red,
+/// so every root-to-leaf path has the same number of black nodes.
+fn build_tree(kids: &[usize], depth: usize, perfect: bool, height: usize, tree: &mut [(u32, u32, u32, u8)], dir_index: &[u32]) -> u32 {
+ if kids.is_empty() {
+ return NOSTREAM;
+ }
+ let mid = kids.len() / 2;
+ let n = kids[mid];
+ let left = build_tree(&kids[..mid], depth + 1, perfect, height, tree, dir_index);
+ let right = build_tree(&kids[mid + 1..], depth + 1, perfect, height, tree, dir_index);
+ tree[n].0 = left;
+ tree[n].1 = right;
+ tree[n].3 = if !perfect && depth == height { 0 } else { 1 };
+ dir_index[n]
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 66174f3b..5c806593 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -30,6 +30,7 @@ filmcraft-scopes = { workspace = true }
filmcraft-text = { workspace = true }
filmcraft-audio-dsp = { workspace = true }
filmcraft-export = { workspace = true }
+filmcraft-mxf = { workspace = true }
filmcraft-format = { workspace = true }
filmcraft-interchange = { workspace = true }
filmcraft-captions = { workspace = true }
diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs
new file mode 100644
index 00000000..a2582112
--- /dev/null
+++ b/crates/engine/src/aaf_omf.rs
@@ -0,0 +1,421 @@
+//! File ▸ Export ▸ AAF… / OMF… and File ▸ Import of AAF / OMF documents.
+//!
+//! The interchange crate writes the documents; this module prepares their audio essence the way
+//! Premiere's AAF and OMF export dialogs offer it: embedded in the document or written as separate
+//! WAV / AIFF files next to it, at a chosen sample rate and bit depth, trimmed to the used ranges
+//! plus handles (or whole), with clip effects rendered in, broken out to mono; and for AAF an
+//! optional video mixdown rendered to one file.
+//!
+//! `file.exportAaf {path, sequence?, mixdownVideo?, breakoutToMono?, audio?: "embedded"|"separate"|
+//! "linked", audioFormat?: "wav"|"aiff"|"mxf" (OP-Atom, AAF only), sampleRate?, bitDepth?, trimAudio?, handles? (frames),
+//! renderAudioEffects?, smallSectors?}`
+//!
+//! `file.exportOmf {path, sequence?, audio?: "embedded"|"separate", audioFormat?, sampleRate?,
+//! bitDepth?, trimAudio? (default on), handles?, renderAudioEffects?, breakoutToMono?}`
+//!
+//! `file.importAaf {path}` (also `file.import` of `.aaf` / `.omf` files).
+
+use std::sync::{Arc, Mutex};
+
+use serde_json::{Value, json};
+
+use filmcraft_interchange::essence::{AudioEssence, AudioNeed, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, audio_needs};
+use filmcraft_project::{ItemId, Project, SequenceSettings, TrackKind};
+use filmcraft_time::{Tick, TimeRange};
+
+use crate::commands::{bad, bool_p, str_p, u64_p};
+use crate::{EngineError, Result, Session};
+
+/// Where exported audio goes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AudioMode {
+ /// Inside the document.
+ Embedded,
+ /// Separate WAV / AIFF files next to the document.
+ Separate,
+ /// The original media files (AAF only; untrimmed, unrendered).
+ Linked,
+}
+
+/// The audio settings of the AAF / OMF export dialogs.
+#[derive(Clone, Debug)]
+pub struct AudioPlan {
+ pub mode: AudioMode,
+ pub aiff: bool,
+ /// Separate files as OP-Atom MXF (AAF only; Avid-style consolidated media).
+ pub mxf: bool,
+ pub sample_rate: u32,
+ pub bits: u16,
+ pub trim: bool,
+ pub handles: Tick,
+ pub render_effects: bool,
+ pub breakout: bool,
+}
+
+fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Result {
+ let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
+ let mode = match str_p(p, "audio").unwrap_or("embedded") {
+ "embedded" | "embed" | "encapsulate" => AudioMode::Embedded,
+ "separate" | "separateAudio" => AudioMode::Separate,
+ "linked" | "link" if !omf => AudioMode::Linked,
+ other => return Err(bad(cmd, format!("unknown audio mode {other:?} (embedded, separate{})", if omf { "" } else { " or linked" }))),
+ };
+ let (aiff, mxf) = match str_p(p, "audioFormat").unwrap_or("wav") {
+ "wav" | "bwf" | "broadcastWave" => (false, false),
+ "aiff" | "aif" => (true, false),
+ "mxf" | "opatom" if !omf => (false, true),
+ other => return Err(bad(cmd, format!("unknown audio format {other:?} (wav, aiff{})", if omf { "" } else { " or mxf" }))),
+ };
+ let sample_rate = u64_p(p, "sampleRate").map(|r| r as u32).unwrap_or(q.settings.sample_rate.max(1));
+ if !(8_000..=192_000).contains(&sample_rate) {
+ return Err(bad(cmd, "sampleRate must be 8000–192000"));
+ }
+ let bits = u64_p(p, "bitDepth").unwrap_or(16) as u16;
+ if bits != 16 && bits != 24 {
+ return Err(bad(cmd, "bitDepth must be 16 or 24"));
+ }
+ let frames = u64_p(p, "handles").unwrap_or(if omf { 30 } else { 0 }) as i64;
+ Ok(AudioPlan {
+ mode,
+ aiff,
+ mxf,
+ sample_rate,
+ bits,
+ trim: bool_p(p, "trimAudio").unwrap_or(omf),
+ handles: q.settings.frame_rate.tick_of(frames),
+ render_effects: bool_p(p, "renderAudioEffects").unwrap_or(false),
+ breakout: bool_p(p, "breakoutToMono").unwrap_or(false),
+ })
+}
+
+fn quantize(planar: &[&Vec], bits: u16) -> Vec {
+ let n = planar.first().map_or(0, |c| c.len());
+ let bps = if bits >= 24 { 3 } else { 2 };
+ let mut out = Vec::with_capacity(n * planar.len() * bps);
+ for i in 0..n {
+ for c in planar {
+ let x = c[i].clamp(-1.0, 1.0);
+ if bps == 3 {
+ out.extend_from_slice(&((x * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
+ } else {
+ out.extend_from_slice(&((x * 32_767.0).round() as i16).to_le_bytes());
+ }
+ }
+ }
+ out
+}
+
+fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec> {
+ let bps = bits.div_ceil(8) as usize;
+ let ch = channels.max(1);
+ let n = pcm.len() / (bps * ch).max(1);
+ let mut out = vec![Vec::with_capacity(n); ch];
+ for i in 0..n {
+ for (c, o) in out.iter_mut().enumerate() {
+ let s = &pcm[(i * ch + c) * bps..(i * ch + c + 1) * bps];
+ let v = match bps {
+ 1 => (s[0] as f32 - 128.0) / 128.0,
+ 2 => i16::from_le_bytes([s[0], s[1]]) as f32 / 32_768.0,
+ 3 => (i32::from_le_bytes([0, s[0], s[1], s[2]]) >> 8) as f32 / 8_388_608.0,
+ _ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32 / 2_147_483_648.0,
+ };
+ o.push(v);
+ }
+ }
+ out
+}
+
+/// Decode a media range at `sample_rate` (planar).
+fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result>> {
+ let src = s
+ .media
+ .full_res_source(&s.project, need.item, &*s.services)
+ .ok_or_else(|| EngineError::Other(format!("media {} is offline", need.path.as_deref().unwrap_or("?"))))?;
+ let start = need.start.to_units_floor(sample_rate as i64);
+ let frames = (need.end.to_units_floor(sample_rate as i64) - start).max(0) as usize;
+ let mut out: Vec> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize];
+ let chunk = sample_rate as usize * 10;
+ let mut done = 0usize;
+ while done < frames {
+ let n = chunk.min(frames - done);
+ let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
+ for (c, o) in out.iter_mut().enumerate() {
+ match b.channels.get(c).or(b.channels.first()) {
+ Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
+ None => o.extend(std::iter::repeat_n(0.0, n)),
+ }
+ }
+ done += n;
+ }
+ Ok(out)
+}
+
+/// Render one clip (with its effects, gain and volume) over a media range, through the export
+/// pipeline's audio mixer.
+fn render_clip(s: &Session, seq: ItemId, need: &AudioNeed, sample_rate: u32, bits: u16) -> Result>> {
+ let EssenceKey::Clip(clip_id) = need.key else { return decode(s, need, sample_rate) };
+ let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
+ let (track, c) = q
+ .tracks(TrackKind::Audio)
+ .iter()
+ .find_map(|t| t.items.iter().find(|c| c.id == clip_id).map(|c| (t, c.clone())))
+ .ok_or_else(|| EngineError::Other("clip not found".into()))?;
+ let mut p: Project = (*s.project).clone();
+ let settings = SequenceSettings { sample_rate, ..q.settings.clone() };
+ let rs = p.new_sequence("AAF audio render", settings, 0, 1, None);
+ let dur = need.end - need.start;
+ let mut c = c;
+ c.start = Tick::ZERO;
+ c.source_in = need.start;
+ c.duration = dur;
+ c.speed = 1.0;
+ c.reverse = false;
+ c.link = None;
+ let channels = match track.channels {
+ filmcraft_project::AudioChannels::Mono => 1,
+ filmcraft_project::AudioChannels::Surround51 => 6,
+ _ => 2,
+ };
+ if let Some(t) = p.sequence_mut(rs).and_then(|r| r.audio_tracks.first_mut()) {
+ t.channels = track.channels;
+ t.items = vec![c];
+ }
+ let buf: Arc>> = Arc::default();
+ let sink_buf = buf.clone();
+ let mut settings = filmcraft_export::ExportSettings {
+ format: filmcraft_export::Format::Wav,
+ path: "render.wav".into(),
+ range: Some(TimeRange::new(Tick::ZERO, dur)),
+ part_of_batch: true,
+ sink: Some(filmcraft_export::OutputSink(Arc::new(move |_: &str, d: Vec| {
+ *sink_buf.lock().unwrap_or_else(|e| e.into_inner()) = d;
+ Ok(())
+ }))),
+ ..Default::default()
+ };
+ settings.audio.sample_rate = Some(sample_rate);
+ settings.audio.channels = channels;
+ settings.audio.bits = bits;
+ let project = Arc::new(p);
+ let provider = s.media.full_res_provider(project.clone(), s.services.clone());
+ filmcraft_export::export(&project, rs, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
+ let wav = std::mem::take(&mut *buf.lock().unwrap_or_else(|e| e.into_inner()));
+ let (pcm, ch, _, b) = filmcraft_interchange::wav::parse_wav(&wav).ok_or_else(|| EngineError::Other("audio render produced no WAVE data".into()))?;
+ Ok(dequantize(&pcm, ch as usize, b))
+}
+
+fn safe_name(s: &str) -> String {
+ let n: String = s.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
+ let n = n.trim().trim_matches('.').to_string();
+ if n.is_empty() { "audio".into() } else { n }
+}
+
+fn ensure_dir(s: &Session, dir: &str) {
+ if !s.services.export_in_memory() && !dir.is_empty() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+}
+
+/// Prepare the audio essence of an export; returns the essence and the files written.
+pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec, Vec)> {
+ if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
+ return Ok((Vec::new(), Vec::new()));
+ }
+ let needs =
+ audio_needs(&s.project, seq, &NeedOptions { handles: plan.handles, per_clip: plan.render_effects, whole_media: !plan.trim && !plan.render_effects });
+ let mut essence = Vec::new();
+ let mut files = Vec::new();
+ let mut used = std::collections::HashSet::new();
+ for need in &needs {
+ let planar = if plan.render_effects { render_clip(s, seq, need, plan.sample_rate, plan.bits)? } else { decode(s, need, plan.sample_rate)? };
+ let frames = planar.first().map_or(0, Vec::len) as u64;
+ let groups: Vec<(Option, Vec<&Vec>)> =
+ if plan.breakout { planar.iter().enumerate().map(|(c, x)| (Some(c as u32), vec![x])).collect() } else { vec![(None, planar.iter().collect())] };
+ let base = match need.key {
+ EssenceKey::Clip(c) => {
+ let name = s.project.sequence(seq).and_then(|q| q.find_item(c)).map(|(_, ti)| ti.name.clone()).unwrap_or_default();
+ format!("{} {}", safe_name(&name), c.0)
+ }
+ EssenceKey::Media(_) => safe_name(
+ need.path
+ .as_deref()
+ .map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
+ .as_deref()
+ .unwrap_or("audio"),
+ ),
+ };
+ for (ch, chans) in groups {
+ let pcm = quantize(&chans, plan.bits);
+ let n = chans.len() as u32;
+ let data = match plan.mode {
+ AudioMode::Embedded => EssenceData::Embedded(pcm),
+ AudioMode::Separate | AudioMode::Linked => {
+ let ext = if plan.mxf {
+ "mxf"
+ } else if plan.aiff {
+ "aif"
+ } else {
+ "wav"
+ };
+ let stem = match ch {
+ Some(c) => format!("{base}_A{}", c + 1),
+ None => base.clone(),
+ };
+ let mut name = format!("{stem}.{ext}");
+ let mut k = 1;
+ while !used.insert(name.to_ascii_lowercase()) {
+ k += 1;
+ name = format!("{stem} ({k}).{ext}");
+ }
+ let path = if media_dir.is_empty() { name.clone() } else { format!("{media_dir}/{name}") };
+ let bytes = if plan.mxf {
+ let bps = plan.bits.div_ceil(8) as usize;
+ let samples: Vec = pcm
+ .chunks_exact(bps)
+ .map(|b| if bps == 3 { i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8 } else { i16::from_le_bytes([b[0], b[1]]) as i32 })
+ .collect();
+ let opts = filmcraft_mxf::OpAtomPcm {
+ sample_rate: plan.sample_rate,
+ bits: plan.bits as u32,
+ channels: n,
+ edit_rate: None,
+ ids: filmcraft_mxf::PackageIds::from_seed(&path, &name),
+ timecode: None,
+ };
+ filmcraft_mxf::write_opatom_pcm(&opts, &samples).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
+ } else if plan.aiff {
+ filmcraft_interchange::wav::aiff_file(&pcm, n as u16, plan.sample_rate, plan.bits)
+ } else {
+ filmcraft_interchange::wav::wav_file(&pcm, n as u16, plan.sample_rate, plan.bits)
+ };
+ s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ files.push(path.clone());
+ EssenceData::File { path }
+ }
+ };
+ essence.push(AudioEssence {
+ key: need.key,
+ channel: ch,
+ start: need.start,
+ frames,
+ sample_rate: plan.sample_rate,
+ bits: plan.bits,
+ channels: n,
+ data,
+ effects_rendered: plan.render_effects,
+ });
+ }
+ }
+ Ok((essence, files))
+}
+
+/// Render the video of `seq` to one file for an AAF video mixdown.
+fn mixdown(s: &Session, seq: ItemId, path: &str) -> Result {
+ let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
+ let dur = q.duration();
+ if dur <= Tick::ZERO {
+ return Err(EngineError::Other("the sequence is empty".into()));
+ }
+ let format = if path.to_ascii_lowercase().ends_with(".mxf") {
+ filmcraft_export::Format::from_name("mxf-opatom").unwrap_or(filmcraft_export::Format::DnxHr)
+ } else {
+ filmcraft_export::Format::DnxHr
+ };
+ let mut settings = filmcraft_export::ExportSettings {
+ format,
+ path: path.to_string(),
+ range: Some(TimeRange::new(Tick::ZERO, dur)),
+ include_audio: false,
+ part_of_batch: true,
+ dnx_profile: "hq".into(),
+ ..Default::default()
+ };
+ if s.services.export_in_memory() {
+ let services = s.services.clone();
+ settings.sink = Some(filmcraft_export::OutputSink(Arc::new(move |p: &str, d: Vec| services.write_file(p, &d))));
+ }
+ let project = s.project.clone();
+ let provider = s.media.full_res_provider(project.clone(), s.services.clone());
+ filmcraft_export::export(&project, seq, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(format!("video mixdown: {e}")))?;
+ Ok(MixdownVideo { path: path.to_string(), start: Tick::ZERO, duration: dur, width: q.settings.width, height: q.settings.height })
+}
+
+fn report_json(r: &filmcraft_interchange::Report) -> Vec {
+ r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
+}
+
+fn split(path: &str) -> (String, String) {
+ let p = std::path::Path::new(path);
+ let dir = p.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
+ let stem = p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Export".into());
+ (dir, stem)
+}
+
+fn join(dir: &str, name: &str) -> String {
+ if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }
+}
+
+/// `file.exportAaf`
+pub fn export_aaf(s: &mut Session, p: &Value) -> Result {
+ let cmd = "file.exportAaf";
+ let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
+ let seq = crate::export_tools::sequence_param(s, p, cmd)?;
+ let plan = plan_from(s, p, seq, cmd, false)?;
+ let (dir, stem) = split(&path);
+ let media_dir = join(&dir, &format!("{stem} Media"));
+ let mut files = Vec::new();
+ if plan.mode != AudioMode::Embedded || bool_p(p, "mixdownVideo").unwrap_or(false) {
+ ensure_dir(s, &media_dir);
+ }
+ let (essence, written) = prepare_audio(s, seq, &plan, &media_dir)?;
+ files.extend(written);
+ let mixdown_video = if bool_p(p, "mixdownVideo").unwrap_or(false) {
+ let ext = if str_p(p, "mixdownFormat") == Some("mxf") { "mxf" } else { "mov" };
+ let m = mixdown(s, seq, &join(&media_dir, &format!("{} Video Mixdown.{ext}", safe_name(&stem))))?;
+ files.push(m.path.clone());
+ Some(m)
+ } else {
+ None
+ };
+ let opts = filmcraft_interchange::aaf::AafOptions {
+ name: s.project.item(seq).map(|i| i.name.clone()),
+ media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: false, essence, mixdown_video },
+ small_sectors: bool_p(p, "smallSectors").unwrap_or(false),
+ };
+ let (bytes, report) = filmcraft_interchange::aaf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
+ s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
+}
+
+/// `file.exportOmf`
+pub fn export_omf(s: &mut Session, p: &Value) -> Result {
+ let cmd = "file.exportOmf";
+ let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
+ let seq = crate::export_tools::sequence_param(s, p, cmd)?;
+ let plan = plan_from(s, p, seq, cmd, true)?;
+ let (dir, stem) = split(&path);
+ let media_dir = join(&dir, &format!("{stem} Audio Files"));
+ if plan.mode == AudioMode::Separate {
+ ensure_dir(s, &media_dir);
+ }
+ let (essence, files) = prepare_audio(s, seq, &plan, &media_dir)?;
+ let opts = filmcraft_interchange::omf::OmfOptions {
+ name: str_p(p, "title").map(str::to_string).or_else(|| s.project.item(seq).map(|i| i.name.clone())),
+ media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: true, essence, mixdown_video: None },
+ };
+ let (bytes, report) = filmcraft_interchange::omf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
+ if bytes.len() as u64 > i32::MAX as u64 {
+ return Err(EngineError::Other("the OMF file would exceed 2 GB; use separate audio files or trim the audio".into()));
+ }
+ s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
+}
+
+/// `file.importAaf` (and OMF): import a document as sequences.
+pub fn import_document(s: &mut Session, p: &Value, cmd: &str) -> Result {
+ let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
+ let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let format = crate::interchange::detect(&path, &bytes).filter(|f| matches!(f, filmcraft_interchange::Format::Aaf | filmcraft_interchange::Format::Omf));
+ let format = format.ok_or_else(|| bad(cmd, format!("{path} is not an AAF or OMF file")))?;
+ crate::interchange::import(s, &path, &bytes, format)
+}
diff --git a/crates/engine/src/aaf_omf_tests.rs b/crates/engine/src/aaf_omf_tests.rs
new file mode 100644
index 00000000..d0a6d82c
--- /dev/null
+++ b/crates/engine/src/aaf_omf_tests.rs
@@ -0,0 +1,224 @@
+//! File ▸ Export ▸ AAF / OMF and their import, end to end with real media written by our encoders.
+
+use std::sync::Arc;
+
+use filmcraft_project::{ItemId, ItemKind, MediaRef, Param, ParamValue, TrackKind};
+use filmcraft_time::FrameRate;
+use serde_json::json;
+
+use crate::Session;
+use crate::media_test_util::{make_movie, session_with, tmp_dir};
+
+/// Two 2-second ProRes + PCM movies cut back to back on V1/A1 (24 fps).
+fn setup(name: &str) -> (Session, Vec, ItemId, std::path::PathBuf) {
+ let dir = tmp_dir(name);
+ let a = dir.join("a.mov");
+ let b = dir.join("b.mov");
+ make_movie(&a, filmcraft_media::DemoScene::Aurora, 64, 36, 48);
+ make_movie(&b, filmcraft_media::DemoScene::Plasma, 64, 36, 48);
+ let (mut s, items, seq) = session_with(&[&a, &b]);
+ // trim both clips so handles exist: A plays media 12..36, B 6..30
+ let rate = FrameRate::FPS_24;
+ let mut p = (*s.project).clone();
+ let q = p.sequence_mut(seq).unwrap();
+ for t in q.all_tracks_mut() {
+ for (i, c) in t.items.iter_mut().enumerate() {
+ c.start = rate.tick_of(i as i64 * 24);
+ c.source_in = rate.tick_of(if i == 0 { 12 } else { 6 });
+ c.duration = rate.tick_of(24);
+ }
+ }
+ s.project = Arc::new(p);
+ (s, items, seq, dir)
+}
+
+fn audio_of(s: &Session, item: ItemId, start: i64, n: usize) -> Vec> {
+ let src = s.media.full_res_source(&s.project, item, &*s.services).expect("online");
+ src.audio(start, n, 48_000).unwrap().channels
+}
+
+fn imported_seq(r: &serde_json::Value) -> ItemId {
+ let docs = r["documents"].as_array().cloned().unwrap_or_default();
+ let seq = r["sequences"][0].as_u64().or_else(|| docs.first().and_then(|d| d["sequences"][0].as_u64())).unwrap_or_else(|| panic!("no sequence in {r}"));
+ ItemId(seq)
+}
+
+fn path_of(s: &Session, item: ItemId) -> String {
+ match &s.project.item(item).unwrap().kind {
+ ItemKind::Media(m) => match &m.media {
+ MediaRef::File { path } => path.clone(),
+ _ => String::new(),
+ },
+ _ => String::new(),
+ }
+}
+
+#[test]
+fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
+ let (mut s, items, seq, dir) = setup("aaf-embed");
+ let path = dir.join("cut.aaf").to_string_lossy().into_owned();
+ let r = s.execute("file.exportAaf", json!({"path": path, "audio": "embedded", "trimAudio": true, "handles": 6, "sequence": seq.0})).unwrap();
+ assert!(r["mediaFiles"].as_array().unwrap().is_empty(), "{r}");
+ let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
+ let n = imported_seq(&r);
+ let q = s.project.sequence(n).unwrap().clone();
+ let orig = s.project.sequence(seq).unwrap().clone();
+ for kind in [TrackKind::Video, TrackKind::Audio] {
+ let a: Vec<_> = q.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
+ let b: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
+ assert_eq!(a, b, "{kind:?}");
+ }
+ // video links to the original movies, audio to WAV files extracted next to the AAF
+ assert_eq!(path_of(&s, q.video_tracks[0].items[0].item), path_of(&s, items[0]));
+ for (c, oc) in q.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
+ let p = path_of(&s, c.item);
+ assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
+ assert!(!s.project.item(c.item).unwrap().as_media().unwrap().offline);
+ // trimmed: the essence starts 6 frames (handles) before the clip
+ assert_eq!(c.source_in, FrameRate::FPS_24.tick_of(6));
+ let got = audio_of(&s, c.item, c.source_in.to_units_floor(48_000), 4800);
+ let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 4800);
+ for ch in 0..2 {
+ for i in 0..4800 {
+ assert!((got[ch][i] - want[ch][i]).abs() <= 1.0 / 32_768.0 + 1e-6, "ch {ch} sample {i}: {} vs {}", got[ch][i], want[ch][i]);
+ }
+ }
+ }
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn aaf_separate_aiff_breakout_and_linked() {
+ let (mut s, items, seq, dir) = setup("aaf-separate");
+ let path = dir.join("sep.aaf").to_string_lossy().into_owned();
+ let r = s
+ .execute(
+ "file.exportAaf",
+ json!({"path": path, "audio": "separate", "audioFormat": "aiff", "bitDepth": 24, "breakoutToMono": true, "trimAudio": true, "handles": 0}),
+ )
+ .unwrap();
+ let files: Vec = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
+ assert_eq!(files.len(), 4, "two media × two channels: {r}");
+ for f in &files {
+ let b = std::fs::read(f).unwrap();
+ let (pcm, ch, sr, bits) = filmcraft_interchange::wav::parse_aiff(&b).unwrap();
+ assert_eq!((ch, sr, bits), (1, 48_000, 24));
+ assert_eq!(pcm.len(), 24 * 2000 * 3, "one second of mono 24-bit audio (no handles)");
+ }
+ let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
+ let n = imported_seq(&r);
+ let q = s.project.sequence(n).unwrap();
+ let mono: Vec<_> = q.audio_tracks.iter().filter(|t| !t.items.is_empty()).collect();
+ assert_eq!(mono.len(), 2);
+ assert!(mono.iter().all(|t| t.channels == filmcraft_project::AudioChannels::Mono));
+
+ // Avid-style consolidated OP-Atom MXF audio: our MXF reader opens every file
+ let path = dir.join("mxf.aaf").to_string_lossy().into_owned();
+ let r = s.execute("file.exportAaf", json!({"path": path, "audio": "separate", "audioFormat": "mxf", "breakoutToMono": true, "trimAudio": true})).unwrap();
+ let files: Vec = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
+ assert_eq!(files.len(), 4);
+ for f in &files {
+ let b = std::fs::read(f).unwrap();
+ let m = filmcraft_mxf::open(&b).unwrap();
+ assert_eq!(m.operational_pattern.name(), "OP-Atom");
+ let t = m.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
+ assert_eq!(m.tracks[t].stored_sample_frames(), 48_000);
+ }
+ let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
+ let n = imported_seq(&r);
+ let a = &s.project.sequence(n).unwrap().audio_tracks[0].items[0];
+ assert!(path_of(&s, a.item).ends_with(".mxf"));
+ assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline, "the MXF audio links");
+
+ // linked: no media is written, the document points at the original movies
+ let path = dir.join("linked.aaf").to_string_lossy().into_owned();
+ let r = s.execute("file.exportAaf", json!({"path": path, "audio": "linked", "sequence": seq.0})).unwrap();
+ assert!(r["mediaFiles"].as_array().unwrap().is_empty());
+ let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
+ let n = imported_seq(&r);
+ let q = s.project.sequence(n).unwrap();
+ let a = &q.audio_tracks[0].items[0];
+ assert_eq!(path_of(&s, a.item), path_of(&s, items[0]));
+ assert_eq!(a.source_in, FrameRate::FPS_24.tick_of(12));
+ assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline);
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn aaf_video_mixdown() {
+ let (mut s, _items, seq, dir) = setup("aaf-mixdown");
+ let path = dir.join("mix.aaf").to_string_lossy().into_owned();
+ let r = s.execute("file.exportAaf", json!({"path": path, "mixdownVideo": true, "audio": "linked", "sequence": seq.0})).unwrap();
+ let files = r["mediaFiles"].as_array().unwrap();
+ assert_eq!(files.len(), 1);
+ let mov = files[0].as_str().unwrap();
+ assert!(mov.ends_with("Video Mixdown.mov") && std::path::Path::new(mov).exists());
+ let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
+ let n = imported_seq(&r);
+ let q = s.project.sequence(n).unwrap();
+ assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::(), 1);
+ let v = &q.video_tracks[0].items[0];
+ assert_eq!(v.duration, FrameRate::FPS_24.tick_of(48));
+ assert_eq!(path_of(&s, v.item), mov);
+ let m = s.project.item(v.item).unwrap().as_media().unwrap();
+ assert!(!m.offline);
+ assert_eq!(m.info.video.as_ref().map(|v| (v.width, v.height)), Some((64, 36)));
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn omf_renders_clip_effects_into_embedded_audio() {
+ let (mut s, _items, seq, dir) = setup("omf-render");
+ // −6.0206 dB on the first audio clip: half amplitude
+ let mut p = (*s.project).clone();
+ let q = p.sequence_mut(seq).unwrap();
+ q.audio_tracks[0].items[0].effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.020_599_913)));
+ s.project = Arc::new(p);
+ let orig = s.project.sequence(seq).unwrap().clone();
+ let path = dir.join("mix.omf").to_string_lossy().into_owned();
+ let r = s.execute("file.exportOmf", json!({"path": path, "renderAudioEffects": true, "handles": 0, "sequence": seq.0})).unwrap();
+ assert!(r["bytes"].as_u64().unwrap() > 48_000 * 2 * 2, "audio is encapsulated: {r}");
+ let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
+ let n = imported_seq(&r);
+ let q = s.project.sequence(n).unwrap().clone();
+ assert!(q.video_tracks.iter().all(|t| t.items.is_empty()));
+ let a = &q.audio_tracks[0].items;
+ assert_eq!(a.len(), 2);
+ assert_eq!(
+ a.iter().map(|c| (c.start, c.duration)).collect::>(),
+ orig.audio_tracks[0].items.iter().map(|c| (c.start, c.duration)).collect::>()
+ );
+ // no gain left on the clips: it is in the samples
+ assert!(a.iter().all(|c| c.effect("volume").and_then(|e| e.param("level")).is_none_or(|p| p.value.as_f64() == Some(0.0))));
+ let oc = &orig.audio_tracks[0].items[0];
+ let got = audio_of(&s, a[0].item, a[0].source_in.to_units_floor(48_000), 2400);
+ let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 2400);
+ let peak = want[0].iter().fold(0f32, |m, x| m.max(x.abs()));
+ assert!(peak > 0.01, "the demo scene has audio");
+ for i in 0..2400 {
+ assert!((got[0][i] - want[0][i] * 0.5).abs() < 2e-3, "sample {i}: {} vs {}", got[0][i], want[0][i] * 0.5);
+ }
+ // the second clip is unchanged
+ let oc2 = &orig.audio_tracks[0].items[1];
+ let got = audio_of(&s, a[1].item, a[1].source_in.to_units_floor(48_000), 2400);
+ let want = audio_of(&s, oc2.item, oc2.source_in.to_units_floor(48_000), 2400);
+ for i in 0..2400 {
+ assert!((got[0][i] - want[0][i]).abs() < 2e-3);
+ }
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn commands_are_registered_with_menus_and_validate_params() {
+ for (id, menu) in [("file.exportAaf", &["File", "Export"][..]), ("file.exportOmf", &["File", "Export"][..]), ("file.importAaf", &[][..])] {
+ let c = crate::commands::find(id).unwrap_or_else(|| panic!("{id}"));
+ assert_eq!(c.menu, menu);
+ }
+ let (mut s, _, seq, dir) = setup("aaf-params");
+ let path = dir.join("x.aaf").to_string_lossy().into_owned();
+ assert!(s.execute("file.exportAaf", json!({"sequence": seq.0})).is_err(), "path is required");
+ assert!(s.execute("file.exportAaf", json!({"path": path, "bitDepth": 20})).is_err());
+ assert!(s.execute("file.exportOmf", json!({"path": path, "audio": "linked"})).is_err(), "OMF has no linked mode");
+ assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
+ let _ = std::fs::remove_dir_all(dir);
+}
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 2404fea2..c0cc1c42 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -752,6 +752,25 @@ fn build() -> Vec {
q["format"] = json!("otio");
crate::interchange::export(s, &q)
}),
+ cmd!(
+ "file.exportAaf",
+ "AAF…",
+ ["File", "Export"],
+ None,
+ r#"{"path":str,"sequence":id?,"mixdownVideo":bool?,"mixdownFormat":"mov|mxf"?,"breakoutToMono":bool?,"audio":"embedded|separate|linked"?,"audioFormat":"wav|aiff|mxf"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"smallSectors":bool?}"#,
+ has_seq,
+ |s, p| crate::aaf_omf::export_aaf(s, p)
+ ),
+ cmd!(
+ "file.exportOmf",
+ "OMF…",
+ ["File", "Export"],
+ None,
+ r#"{"path":str,"sequence":id?,"title":str?,"audio":"embedded|separate"?,"audioFormat":"wav|aiff"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"breakoutToMono":bool?}"#,
+ has_seq,
+ |s, p| crate::aaf_omf::export_omf(s, p)
+ ),
+ cmd!("file.importAaf", "Import AAF…", [], None, r#"{"path":str}"#, always, |s, p| crate::aaf_omf::import_document(s, p, "file.importAaf")),
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| {
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;
write_project(s, &path, true)
@@ -862,7 +881,7 @@ fn build() -> Vec {
"Media…",
["File", "Export"],
None,
- r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
+ r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),
diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs
index fc0c0381..4ecce8dc 100644
--- a/crates/engine/src/export_tests.rs
+++ b/crates/engine/src/export_tests.rs
@@ -124,6 +124,18 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
assert_eq!((v["width"].as_u64().unwrap(), v["height"].as_u64().unwrap()), (640, 360));
continue;
}
+ "mxf-opatom" => {
+ // Avid style: picture only in Demo.mxf, one mono PCM file per channel
+ assert_eq!(fmt_name, "mxf", "{name}");
+ assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}");
+ assert_eq!(stream(&j, "video").unwrap()["codec_name"], "dnxhd", "{name}");
+ for k in 1..=2 {
+ let a = probe(&format!("{}_A{k}.mxf", out.trim_end_matches(".mxf"))).unwrap();
+ let a = stream(&a, "audio").unwrap();
+ assert_eq!((a["codec_name"].as_str(), a["channels"].as_u64()), (Some("pcm_s24le"), Some(1)), "{name} A{k}");
+ }
+ continue;
+ }
_ => {}
}
let v = stream(&j, "video").unwrap_or_else(|| panic!("{name}: no video"));
@@ -167,6 +179,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
assert!((vbr / (mbps * px) - 1.0).abs() < 0.25, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
}
+ "mxf-op1a" => {
+ assert_eq!(fmt_name, "mxf", "{name}");
+ let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
+ "proRes" => "prores",
+ "h264" => "h264",
+ _ => "dnxhd",
+ };
+ assert_eq!(v["codec_name"], codec, "{name}");
+ assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
+ }
other => panic!("{name}: unexpected format {other}"),
}
}
@@ -222,7 +244,7 @@ fn user_presets_persist_with_favourites_import_and_export() {
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
assert_eq!(favs, 2);
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
- assert_eq!(hits, 4);
+ assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
// persisted: a fresh session with the same data directory sees both
let mut s2 = demo();
s2.export_presets.set_dir(&dir.0);
diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs
index 53de39ee..c6b0f393 100644
--- a/crates/engine/src/export_tools.rs
+++ b/crates/engine/src/export_tools.rs
@@ -209,6 +209,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
if let Some(v) = str_p(p, "dnxProfile") {
settings.dnx_profile = v.to_string();
}
+ if let Some(v) = str_p(p, "mxfVideoCodec") {
+ settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
+ }
if let Some(v) = bool_p(p, "sdr") {
settings.sdr = v;
}
diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs
index c84693b2..b961eea2 100644
--- a/crates/engine/src/interchange.rs
+++ b/crates/engine/src/interchange.rs
@@ -1,4 +1,4 @@
-//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO) ↔ the session's project.
+//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
use serde_json::{Value, json};
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
/// The interchange format of a file, if it is one (by content, with the extension as a hint).
pub fn detect(path: &str, bytes: &[u8]) -> Option {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
- if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio")) {
+ if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio" | "aaf" | "omf" | "omfi")) {
return None;
}
filmcraft_interchange::detect(bytes, ext.as_deref())
@@ -29,7 +29,21 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
name: p.file_stem().map(|n| n.to_string_lossy().to_string()),
..Default::default()
};
- let (fragment, report) = filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let (fragment, report) = match format {
+ // AAF / OMF may embed audio: write it next to the document (the media items point there)
+ Format::Aaf | Format::Omf => {
+ let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
+ let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ for m in extracted {
+ if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
+ }
+ (fragment, report)
+ }
+ _ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?,
+ };
let before: std::collections::HashSet = s.project.items.keys().copied().collect();
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
@@ -100,6 +114,8 @@ pub fn export(s: &mut Session, p: &Value) -> Result {
"edl" => Format::Edl,
"fcpxml" => Format::Fcpxml,
"otio" => Format::Otio,
+ "aaf" => Format::Aaf,
+ "omf" => Format::Omf,
_ => Format::Fcp7Xml,
};
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 23447bc2..c41b8f03 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -8,6 +8,7 @@
//! The project is an `Arc` edited copy-on-write; undo keeps whole-project snapshots (cheap
//! thanks to structural sharing of untouched items).
+pub mod aaf_omf;
pub mod autosave;
pub mod captions;
pub mod clip_ops;
@@ -852,6 +853,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option Result {
let r = match &err {
// stopping keeps what was tracked
Some(e) if e != "stopped" => Err(e.clone()),
- _ => Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }),
+ _ => Ok(filmcraft_export::Report {
+ path: String::new(),
+ frames: done,
+ seconds: secs,
+ bytes: 0,
+ render_fps: done as f64 / secs.max(1e-6),
+ extra_files: Vec::new(),
+ }),
};
*lock(&prog.status) = match &err {
Some(e) => format!("{e} ({done} frame(s) tracked)"),
diff --git a/crates/engine/src/panels_tests.rs b/crates/engine/src/panels_tests.rs
index 5781c2a2..357e995a 100644
--- a/crates/engine/src/panels_tests.rs
+++ b/crates/engine/src/panels_tests.rs
@@ -141,7 +141,8 @@ fn events_report_background_jobs() {
s.jobs.extend([a.clone(), b.clone(), c.clone()]);
s.poll_persistence();
assert_eq!(s.log.entries.iter().filter(|e| e.message.starts_with("Started:")).count(), 3);
- *a.result.lock().unwrap() = Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0 }));
+ *a.result.lock().unwrap() =
+ Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0, extra_files: Vec::new() }));
*b.result.lock().unwrap() = Some(Err("disk full".into()));
c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
*c.result.lock().unwrap() = Some(Err("cancelled".into()));
diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs
index 130b1175..17a114d4 100644
--- a/crates/engine/src/previews.rs
+++ b/crates/engine/src/previews.rs
@@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result {
seconds: secs,
bytes,
render_fps: done_frames as f64 / secs.max(1e-6),
+ extra_files: Vec::new(),
});
if let Err(e) = &r {
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
@@ -612,7 +613,14 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result {
store.add_audio(&g.hash);
}
let secs = t0.elapsed().as_secs_f64();
- let r = outcome.map(|_| filmcraft_export::Report { path: dir.to_string_lossy().to_string(), frames: 0, seconds: secs, bytes, render_fps: 0.0 });
+ let r = outcome.map(|_| filmcraft_export::Report {
+ path: dir.to_string_lossy().to_string(),
+ frames: 0,
+ seconds: secs,
+ bytes,
+ render_fps: 0.0,
+ extra_files: Vec::new(),
+ });
if let Err(e) = &r {
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
}
diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs
index 04241876..f0319160 100644
--- a/crates/engine/src/project_manager.rs
+++ b/crates/engine/src/project_manager.rs
@@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result {
seconds: secs,
bytes,
render_fps: 0.0,
+ extra_files: Vec::new(),
});
if let Err(e) = &r {
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs
index ff625700..1c0db146 100644
--- a/crates/engine/src/proxies.rs
+++ b/crates/engine/src/proxies.rs
@@ -232,7 +232,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
Err(e)
}
- None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6) }),
+ None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6), extra_files: Vec::new() }),
};
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = match &r {
Ok(_) => format!("Done in {secs:.1}s"),
diff --git a/crates/engine/src/scene_detect.rs b/crates/engine/src/scene_detect.rs
index 1d2b2d8d..f2f823a5 100644
--- a/crates/engine/src/scene_detect.rs
+++ b/crates/engine/src/scene_detect.rs
@@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result {
Some(e) => Err(e),
None => {
*lock(&out) = Some(found);
- Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) })
+ Ok(filmcraft_export::Report {
+ path: String::new(),
+ frames: done,
+ seconds: secs,
+ bytes: 0,
+ render_fps: done as f64 / secs.max(1e-6),
+ extra_files: Vec::new(),
+ })
}
};
*lock(&res) = Some(r);
diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml
index ac44cb56..22c2de4c 100644
--- a/crates/export/Cargo.toml
+++ b/crates/export/Cargo.toml
@@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
filmcraft-isobmff = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-dnx = { workspace = true }
+filmcraft-mxf = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-h264enc = { workspace = true }
serde = { workspace = true }
diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs
index 04796560..22de58c6 100644
--- a/crates/export/src/job.rs
+++ b/crates/export/src/job.rs
@@ -22,6 +22,7 @@ use filmcraft_time::FrameRate;
use rayon::prelude::*;
use crate::audio_out::AudioOut;
+use crate::mxf_out::{MxfMux, MxfSetup};
use crate::pipeline::Pipeline;
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
use crate::{
@@ -59,6 +60,10 @@ pub struct Exporter {
first_pass: bool,
/// Created after the first batch (encoders may finalise their codec config then).
mux: Option>,
+ /// MXF exports write through this instead of `mux`.
+ mxf: Option,
+ seq: ItemId,
+ project: Arc,
vt: usize,
at: Option,
t0: web_time::Instant,
@@ -66,7 +71,7 @@ pub struct Exporter {
/// Whether [`Exporter`] handles a format.
pub fn stepped(format: Format) -> bool {
- matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg)
+ matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
}
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result> {
@@ -74,8 +79,8 @@ fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Resu
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
- .find_map(|fac| fac(settings.format, w, h, rate, settings))
- .ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.format.label())))?
+ .find_map(|fac| fac(settings.video_format(), w, h, rate, settings))
+ .ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.video_format().label())))?
}
impl Exporter {
@@ -88,7 +93,7 @@ impl Exporter {
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
let pipe = q.settings.color;
- let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.format, Format::H264 | Format::ProRes | Format::DnxHr);
+ let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr);
let mut settings = settings.clone();
settings.signal = match (hdr_out, pipe.working) {
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
@@ -101,7 +106,7 @@ impl Exporter {
settings.max_bitrate_kbps = Some(r.max_kbps);
settings.keyframe_distance = Some(r.keyint);
settings.adaptive_bitrate = None;
- let two_pass = settings.format == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass;
+ let two_pass = settings.video_format() == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass;
settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single };
let range = export_range(&project, seq, &settings)?;
let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?;
@@ -136,6 +141,9 @@ impl Exporter {
measured,
first_pass: two_pass,
mux: None,
+ mxf: None,
+ seq,
+ project: project.clone(),
vt: 0,
at: None,
t0: web_time::Instant::now(),
@@ -158,6 +166,12 @@ impl Exporter {
}
fn write_audio(&mut self, planar: Option>>) -> Result<()> {
+ if let Some(m) = self.mxf.as_mut() {
+ return match planar {
+ Some(buf) => m.write_audio(&buf),
+ None => Ok(()),
+ };
+ }
let (Some(at), Some(buf)) = (self.at, planar) else { return Ok(()) };
let n = buf.first().map_or(0, Vec::len);
if n == 0 {
@@ -192,6 +206,9 @@ impl Exporter {
}
fn write_video(&mut self, packets: Vec) -> Result<()> {
+ if let Some(m) = self.mxf.as_mut() {
+ return m.write_video(packets);
+ }
let mux = self.mux.as_mut().expect("mux created");
for p in packets {
mux.write_sample(self.vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key })
@@ -202,6 +219,23 @@ impl Exporter {
/// Create the muxer and its tracks (after the first batch was encoded).
fn open_mux(&mut self) -> Result<()> {
+ if self.settings.format.is_mxf() {
+ let q = self.project.sequence(self.seq).ok_or(ExportError::NoSequence)?;
+ // start timecode of the first exported frame, at the output rate
+ let seq_rate = q.settings.frame_rate;
+ let start = self.pipe.rate.frame_at(seq_rate.tick_of(q.start_timecode)) + self.f0;
+ let name = self.project.item(self.seq).map(|i| i.name.clone()).unwrap_or_default();
+ self.mxf = Some(MxfMux::new(MxfSetup {
+ settings: &self.settings,
+ name,
+ width: self.pipe.w,
+ height: self.pipe.h,
+ rate: self.pipe.rate,
+ audio: self.audio.as_ref().map(|a| (a.sr, a.channels)),
+ timecode: (start, q.settings.drop_frame),
+ })?);
+ return Ok(());
+ }
let file = Out::create(&self.settings)?;
let mut opts = WriterOptions::new(self.brand);
opts.metadata = self.settings.metadata.udta();
@@ -262,7 +296,7 @@ impl Exporter {
return Ok(Step::Progress);
}
let mixed = match self.audio.as_ref().map(|a| a.sr) {
- Some(sr) if self.mux.is_none() || self.at.is_some() => {
+ Some(sr) if (self.mux.is_none() && self.mxf.is_none()) || self.at.is_some() || self.mxf.is_some() => {
let until = self.sample_at_frame(end, sr);
self.audio.as_mut().and_then(|a| a.pull(until, sources))
}
@@ -272,7 +306,7 @@ impl Exporter {
return Ok(Step::Pending);
}
let packets = self.encode(&frames, f)?;
- if self.mux.is_none() {
+ if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
self.write_video(packets)?;
@@ -295,7 +329,7 @@ impl Exporter {
if filmcraft_media::pending::take() {
return Ok(Step::Pending);
}
- if self.mux.is_none() {
+ if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
let tail = self.venc.flush()?;
@@ -309,15 +343,27 @@ impl Exporter {
.map_err(|e| ExportError::Io(e.to_string()))?;
}
}
- let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
- let bytes = w.finish(&self.settings)?;
+ let (bytes, extra_files) = match self.mxf.take() {
+ Some(m) => m.finish(&self.settings)?,
+ None => {
+ let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
+ (w.finish(&self.settings)?, Vec::new())
+ }
+ };
let secs = self.t0.elapsed().as_secs_f64();
let nframes = self.frames();
if !self.settings.part_of_batch {
progress.finished.store(true, Ordering::Relaxed);
progress.set_status(format!("Done in {secs:.1}s"));
}
- Ok(Step::Done(Report { path: self.settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) }))
+ Ok(Step::Done(Report {
+ path: self.settings.path.clone(),
+ frames: nframes,
+ seconds: secs,
+ bytes,
+ render_fps: nframes as f64 / secs.max(1e-6),
+ extra_files,
+ }))
}
fn encode(&mut self, frames: &[(Vec, Vec)], f: i64) -> Result> {
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index d5373cbd..cf73c663 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -16,12 +16,14 @@ use std::io::Write;
mod audio_out;
mod job;
+mod mxf_out;
mod pcm;
mod pipeline;
pub mod presets;
pub mod settings;
pub use audio_out::LoudnessReport;
pub use job::{Exporter, Step, stepped};
+pub use mxf_out::opatom_audio_paths;
pub use pcm::{image_sequence_path, write_aiff, write_wav};
pub use pipeline::limit_rgba8;
pub use presets::{ExportPreset, builtin_presets};
@@ -82,6 +84,13 @@ pub enum Format {
Wav,
#[serde(rename = "aiff", alias = "Aiff")]
Aiff,
+ /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
+ #[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
+ MxfOp1a,
+ /// MXF OP-Atom (SMPTE ST 390, Avid style): the picture in one file, one mono PCM file per
+ /// audio channel ([`opatom_audio_paths`]).
+ #[serde(rename = "mxf-opatom", alias = "MxfOpAtom")]
+ MxfOpAtom,
}
impl Format {
@@ -89,7 +98,9 @@ impl Format {
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
"prores" | "mov" | "appleprores" => Format::ProRes,
- "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" | "mxf" => Format::DnxHr,
+ "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
+ "mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
+ "mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
"png" | "pngsequence" => Format::PngSequence,
"tif" | "tiff" | "tiffsequence" => Format::TiffSequence,
@@ -113,6 +124,8 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
+ Format::MxfOp1a => "mxf-op1a",
+ Format::MxfOpAtom => "mxf-opatom",
}
}
pub fn extension(self) -> &'static str {
@@ -125,6 +138,7 @@ impl Format {
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
+ Format::MxfOp1a | Format::MxfOpAtom => "mxf",
}
}
pub fn label(self) -> &'static str {
@@ -139,9 +153,15 @@ impl Format {
Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF",
+ Format::MxfOp1a => "MXF OP1a",
+ Format::MxfOpAtom => "MXF OP-Atom",
}
}
- pub const ALL: [Format; 10] = [
+ /// An MXF container format.
+ pub fn is_mxf(self) -> bool {
+ matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
+ }
+ pub const ALL: [Format; 12] = [
Format::H264,
Format::ProRes,
Format::DnxHr,
@@ -152,6 +172,8 @@ impl Format {
Format::Gif,
Format::Wav,
Format::Aiff,
+ Format::MxfOp1a,
+ Format::MxfOpAtom,
];
}
@@ -192,6 +214,9 @@ pub struct ExportSettings {
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
#[serde(default)]
pub dnx_profile: String,
+ /// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply).
+ #[serde(default)]
+ pub mxf_video_codec: MxfVideoCodec,
/// Encode display-referred SDR (Rec. 709, tone mapped) even when the sequence works in
/// Rec. 2100 PQ/HLG. Otherwise H.264 and ProRes exports of an HDR sequence are encoded in the
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
@@ -259,21 +284,30 @@ enum Out {
impl Out {
fn create(settings: &ExportSettings) -> Result {
+ Out::create_path(settings, &settings.path)
+ }
+
+ /// An output file at `path` (OP-Atom exports write several).
+ fn create_path(settings: &ExportSettings, path: &str) -> Result {
if settings.sink.is_some() {
return Ok(Out::Mem(std::io::Cursor::new(Vec::new())));
}
- let f = std::fs::File::create(&settings.path).map_err(|e| ExportError::Io(e.to_string()))?;
+ let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?;
Ok(Out::File(std::io::BufWriter::new(f)))
}
/// Flush (and hand in-memory output to the sink); returns the file size.
fn finish(self, settings: &ExportSettings) -> Result {
+ self.finish_path(settings, &settings.path)
+ }
+
+ fn finish_path(self, settings: &ExportSettings, path: &str) -> Result {
match self {
Out::File(mut w) => {
w.flush().map_err(|e| ExportError::Io(e.to_string()))?;
- Ok(std::fs::metadata(&settings.path).map(|m| m.len()).unwrap_or(0))
+ Ok(std::fs::metadata(path).map(|m| m.len()).unwrap_or(0))
}
- Out::Mem(c) => write_output(settings, &settings.path, c.into_inner()),
+ Out::Mem(c) => write_output(settings, path, c.into_inner()),
}
}
}
@@ -372,6 +406,7 @@ impl Default for ExportSettings {
part_of_batch: false,
prores_profile: String::new(),
dnx_profile: String::new(),
+ mxf_video_codec: MxfVideoCodec::default(),
sdr: false,
frame_size: None,
frame_rate: None,
@@ -441,6 +476,9 @@ pub struct Report {
pub seconds: f64,
pub bytes: u64,
pub render_fps: f64,
+ /// Further files written besides `path` (MXF OP-Atom audio files).
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub extra_files: Vec,
}
/// A packet produced by a video encoder.
@@ -883,8 +921,9 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
return None;
}
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
- cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
- cfg.aud = false;
+ // MXF carries the Annex B byte stream (ST 381-3) with in-band parameter sets
+ cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed };
+ cfg.aud = s.format.is_mxf();
cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32);
let kbps = s.bitrate_kbps.max(100);
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
@@ -1069,14 +1108,14 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so
}
(total, count)
}
- Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg => unreachable!("stepped export"),
+ Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => unreachable!("stepped export"),
};
let secs = t0.elapsed().as_secs_f64();
if !settings.part_of_batch {
progress.finished.store(true, Ordering::Relaxed);
progress.set_status(format!("Done in {secs:.1}s"));
}
- Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) })
+ Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6), extra_files: Vec::new() })
}
#[cfg(test)]
@@ -1087,3 +1126,6 @@ mod settings_tests;
#[cfg(test)]
mod surround_tests;
+
+#[cfg(test)]
+mod mxf_tests;
diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs
new file mode 100644
index 00000000..24233e7a
--- /dev/null
+++ b/crates/export/src/mxf_out.rs
@@ -0,0 +1,208 @@
+//! MXF output of the stepped exporter: OP1a (one file, frame-wrapped picture + one PCM track) or
+//! OP-Atom (Avid style: the picture in `path`, one mono PCM file per audio channel next to it,
+//! `_A1.mxf`, `_A2.mxf`, …), written with [`filmcraft_mxf::MxfWriter`].
+
+use filmcraft_mxf::{
+ CodedKind, ColorSpace, FrameInfo, MxfWriter, PackageIds, Pattern, PictureCoding, PictureDesc, Rational, SoundDesc, StartTimecode, Timestamp, WriterConfig,
+};
+use filmcraft_time::FrameRate;
+
+use crate::{ColorSignal, EncodedPacket, ExportError, ExportSettings, Format, H264Profile, Out, Result};
+
+/// Paths of the OP-Atom audio files for an export to `path` with `channels` channels.
+pub fn opatom_audio_paths(path: &str, channels: usize) -> Vec {
+ let p = std::path::Path::new(path);
+ let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "export".into());
+ (1..=channels)
+ .map(|k| {
+ let name = format!("{stem}_A{k}.mxf");
+ match p.parent().filter(|d| !d.as_os_str().is_empty()) {
+ Some(d) => d.join(name).to_string_lossy().to_string(),
+ None => name,
+ }
+ })
+ .collect()
+}
+
+fn io(e: std::io::Error) -> ExportError {
+ ExportError::Io(e.to_string())
+}
+
+/// The picture essence description for an export (codec from the MXF video codec setting).
+fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
+ let coding = match settings.video_format() {
+ Format::ProRes => {
+ use filmcraft_prores::Profile;
+ PictureCoding::ProRes {
+ profile: match crate::prores_profile(&settings.prores_profile) {
+ Profile::Proxy => 1,
+ Profile::Lt => 2,
+ Profile::Standard => 3,
+ _ => 4,
+ },
+ }
+ }
+ Format::H264 => PictureCoding::Avc {
+ profile_idc: match settings.h264_profile {
+ H264Profile::Baseline => 66,
+ H264Profile::Main => 77,
+ H264Profile::High => 100,
+ },
+ intra: false,
+ },
+ _ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() },
+ };
+ let mut d = PictureDesc::new(coding, w, h);
+ if matches!(coding, PictureCoding::ProRes { .. }) {
+ d.depth = 10;
+ }
+ d.color = match settings.signal {
+ s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq,
+ s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg,
+ _ => ColorSpace::Rec709,
+ };
+ if let Some((n, dn)) = settings.pixel_aspect.filter(|(n, d)| *n > 0 && *d > 0 && n != d) {
+ let (a, b) = (w as u64 * n as u64, h as u64 * dn as u64);
+ let g = gcd(a, b);
+ d.aspect = Rational::new((a / g) as i32, (b / g) as i32);
+ }
+ d
+}
+
+fn gcd(a: u64, b: u64) -> u64 {
+ if b == 0 { a.max(1) } else { gcd(b, a % b) }
+}
+
+/// Everything the MXF muxer needs from the export.
+pub(crate) struct MxfSetup<'a> {
+ pub settings: &'a ExportSettings,
+ pub name: String,
+ pub width: u32,
+ pub height: u32,
+ pub rate: FrameRate,
+ /// (sample rate, channels) of the audio, if any.
+ pub audio: Option<(u32, usize)>,
+ /// Start timecode in frames of `rate`, and drop-frame.
+ pub timecode: (i64, bool),
+}
+
+/// An MXF export in progress.
+pub(crate) struct MxfMux {
+ video: MxfWriter,
+ /// OP-Atom: one writer per audio channel.
+ audio: Vec<(String, MxfWriter)>,
+ atom: bool,
+ bits: u32,
+ channels: usize,
+ /// Stored pictures so far, and the composition offset of the first (H.264 reorder delay).
+ stored: i64,
+ first_offset: Option,
+ max_display: i64,
+ frame_den: i64,
+}
+
+impl MxfMux {
+ pub fn new(s: MxfSetup) -> Result {
+ let settings = s.settings;
+ let atom = settings.format == Format::MxfOpAtom;
+ let rate = Rational::new(s.rate.num as i32, s.rate.den as i32);
+ let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
+ let seed = format!("{}|{now}", settings.path);
+ let ids = PackageIds::from_seed(&seed, &s.name);
+ let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
+ let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
+ cfg.picture = Some(picture(settings, s.width, s.height));
+ cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
+ cfg.timecode = Some(StartTimecode { frames: s.timecode.0, rate, drop_frame: s.timecode.1 && s.rate.supports_drop_frame() });
+ if !atom && let Some((sr, ch)) = s.audio {
+ cfg.sound = vec![SoundDesc { sample_rate: sr, channels: ch as u32, bits }];
+ }
+ let mut audio = Vec::new();
+ if atom && let Some((sr, ch)) = s.audio {
+ for (k, path) in opatom_audio_paths(&settings.path, ch).into_iter().enumerate() {
+ // Avid style: the files share the material package; each has its own file package.
+ let mut a = WriterConfig::new(
+ Pattern::OpAtom,
+ rate,
+ PackageIds {
+ material: ids.material,
+ file: filmcraft_mxf::Umid::from_seed(format!("file:{seed}:A{}", k + 1).as_bytes()),
+ material_name: ids.material_name.clone(),
+ file_name: format!("{} A{}", s.name, k + 1),
+ },
+ );
+ a.first_track_id = 3 + k as u32;
+ a.sound = vec![SoundDesc { sample_rate: sr, channels: 1, bits }];
+ a.timecode = cfg.timecode;
+ a.modified = cfg.modified;
+ let out = Out::create_path(settings, &path)?;
+ audio.push((path, MxfWriter::new(out, a).map_err(io)?));
+ }
+ }
+ let video = MxfWriter::new(Out::create_path(settings, &settings.path)?, cfg).map_err(io)?;
+ Ok(MxfMux {
+ video,
+ audio,
+ atom,
+ bits,
+ channels: s.audio.map_or(0, |a| a.1),
+ stored: 0,
+ first_offset: None,
+ max_display: -1,
+ frame_den: s.rate.den.max(1),
+ })
+ }
+
+ pub fn write_video(&mut self, packets: Vec) -> Result<()> {
+ for p in packets {
+ let intra = !matches!(self.video.config().picture.as_ref().map(|p| p.coding), Some(PictureCoding::Avc { .. }));
+ let info = if intra {
+ FrameInfo::intra()
+ } else {
+ // display position from the composition offset (pts − dts) relative to the first
+ let off = p.composition_offset as i64;
+ let first = *self.first_offset.get_or_insert(off);
+ let display = self.stored + (off - first) / self.frame_den;
+ let kind = if p.key {
+ CodedKind::Intra
+ } else if display < self.max_display {
+ CodedKind::Bidirectional
+ } else {
+ CodedKind::Predicted
+ };
+ self.max_display = self.max_display.max(display);
+ FrameInfo { key: p.key, kind, display: Some(display) }
+ };
+ self.video.push_picture(&p.data, info).map_err(io)?;
+ self.stored += 1;
+ }
+ Ok(())
+ }
+
+ pub fn write_audio(&mut self, planar: &[Vec]) -> Result<()> {
+ if self.channels == 0 || planar.first().is_none_or(Vec::is_empty) {
+ return Ok(());
+ }
+ if self.atom {
+ for (k, (_, w)) in self.audio.iter_mut().enumerate() {
+ if let Some(c) = planar.get(k) {
+ w.push_sound(0, &filmcraft_mxf::encode_pcm(std::slice::from_ref(c), self.bits)).map_err(io)?;
+ }
+ }
+ Ok(())
+ } else {
+ self.video.push_sound(0, &filmcraft_mxf::encode_pcm(planar, self.bits)).map_err(io)
+ }
+ }
+
+ /// Finish every file; returns (total bytes, extra files written besides `settings.path`).
+ pub fn finish(self, settings: &ExportSettings) -> Result<(u64, Vec)> {
+ let mut total = self.video.finish().map_err(io)?.finish_path(settings, &settings.path)?;
+ let mut extra = Vec::new();
+ for (path, w) in self.audio {
+ total += w.finish().map_err(io)?.finish_path(settings, &path)?;
+ extra.push(path);
+ }
+ Ok((total, extra))
+ }
+}
diff --git a/crates/export/src/mxf_tests.rs b/crates/export/src/mxf_tests.rs
new file mode 100644
index 00000000..27576e74
--- /dev/null
+++ b/crates/export/src/mxf_tests.rs
@@ -0,0 +1,261 @@
+//! MXF export (OP1a / OP-Atom): read back with our demuxer and decoders, and against ffprobe /
+//! ffmpeg as external oracles (decode must succeed and match the same essence exported as MOV;
+//! PCM sample-exact).
+
+use std::path::Path;
+use std::process::Command;
+
+use super::*;
+use filmcraft_media::generators::GeneratorSource;
+use filmcraft_media::{FrameRequest, Generator, MediaSource};
+use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind};
+use filmcraft_render::SourceMap;
+use filmcraft_time::TICKS_PER_SECOND;
+
+/// 1 s at 24 fps, 320×180: the counting leader over V1, a 440 Hz tone on A1 (stereo mix), start
+/// timecode 01:00:00:00.
+fn project() -> (Arc, ItemId, SourceMap) {
+ let mut p = Project::new("mxf");
+ let r = FrameRate::FPS_24;
+ let leader = GeneratorSource::new(Generator::CountingLeader, 320, 180, r, Tick(2 * TICKS_PER_SECOND));
+ let tone = GeneratorSource::new(Generator::Tone { hz: 440.0, db: -6.0 }, 320, 180, r, Tick(2 * TICKS_PER_SECOND));
+ let add = |p: &mut Project, g: &GeneratorSource| {
+ let info = g.info().clone();
+ p.add_item(
+ &info.name.clone(),
+ Label::Iris,
+ ItemKind::Media(MediaClip {
+ media: MediaRef::Generator(g.generator.clone()),
+ info,
+ interpret: Default::default(),
+ mark_in: None,
+ mark_out: None,
+ markers: vec![],
+ offline: false,
+ proxy: None,
+ identity: None,
+ }),
+ None,
+ )
+ };
+ let v = add(&mut p, &leader);
+ let a = add(&mut p, &tone);
+ let seq = p.new_sequence("MXF Test", SequenceSettings { width: 320, height: 180, frame_rate: r, ..Default::default() }, 1, 1, None);
+ let vi = p.make_track_item(v, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap();
+ let ai = p.make_track_item(a, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap();
+ let q = p.sequence_mut(seq).unwrap();
+ q.video_tracks[0].items.push(vi);
+ q.audio_tracks[0].items.push(ai);
+ q.start_timecode = 24 * 3600;
+ let mut m = SourceMap::default();
+ m.0.insert(v, Arc::new(leader));
+ m.0.insert(a, Arc::new(tone));
+ (Arc::new(p), seq, m)
+}
+
+fn tmp(name: &str) -> String {
+ let d = std::env::temp_dir().join(format!("fc-export-mxf-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ d.join(name).to_string_lossy().to_string()
+}
+
+fn settings(format: Format, codec: MxfVideoCodec, path: &str) -> ExportSettings {
+ ExportSettings {
+ format,
+ path: path.into(),
+ mxf_video_codec: codec,
+ audio: AudioSettings { bits: 24, sample_rate: Some(48_000), channels: 2, ..Default::default() },
+ ..Default::default()
+ }
+}
+
+fn ffprobe_json(ffprobe: &Path, path: &str) -> serde_json::Value {
+ let out = Command::new(ffprobe).args(["-v", "error", "-show_format", "-show_streams", "-count_frames", "-of", "json", path]).output().unwrap();
+ assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
+ serde_json::from_slice(&out.stdout).unwrap()
+}
+
+/// ffmpeg's decode of `path` (stream `map`) as raw bytes in `fmt`.
+fn ffmpeg_raw(ffmpeg: &Path, path: &str, map: &str, args: &[&str]) -> Vec {
+ let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-map", map]).args(args).arg("-").output().unwrap();
+ assert!(out.status.success(), "ffmpeg {path}: {}", String::from_utf8_lossy(&out.stderr));
+ out.stdout
+}
+
+fn codec_name(c: MxfVideoCodec) -> &'static str {
+ match c {
+ MxfVideoCodec::Dnxhr => "dnxhd",
+ MxfVideoCodec::ProRes => "prores",
+ MxfVideoCodec::H264 => "h264",
+ }
+}
+
+#[test]
+fn op1a_reads_back_with_our_demuxer_and_decoders() {
+ let (p, seq, m) = project();
+ for codec in MxfVideoCodec::ALL {
+ let path = tmp(&format!("op1a_{codec:?}.mxf"));
+ let r = export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap();
+ assert_eq!(r.frames, 24);
+ assert!(r.extra_files.is_empty());
+ let bytes = std::fs::read(&path).unwrap();
+ let mx = filmcraft_mxf::open(&bytes).unwrap();
+ assert!(mx.warnings.is_empty(), "{codec:?}: {:?}", mx.warnings);
+ assert_eq!(mx.operational_pattern.name(), "OP1a");
+ assert_eq!(mx.material_package_name.as_deref(), Some("MXF Test"));
+ assert_eq!(mx.timecode.unwrap().format(), "01:00:00:00");
+ let v = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Picture).unwrap()];
+ assert_eq!(v.samples.len(), 24, "{codec:?}");
+ assert_eq!(v.duration, Some(24));
+ let a = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap()];
+ assert_eq!(a.stored_sample_frames(), 48_000, "{codec:?}");
+ assert_eq!(a.sound.as_ref().unwrap().bits, 24);
+ if codec == MxfVideoCodec::H264 {
+ assert!(v.temporal_offsets, "B pictures: the index gives the presentation order");
+ assert!(v.samples[0].key && v.samples.iter().filter(|s| s.key).count() < 24);
+ }
+ // our MXF source decodes it like the MOV export of the same sequence
+ let src = filmcraft_codecs::open_bytes("x.mxf", bytes.into()).unwrap();
+ let info = src.info();
+ assert_eq!(info.video.as_ref().unwrap().width, 320, "{codec:?}");
+ assert!((info.duration.seconds() - 1.0).abs() < 0.05, "{codec:?}: {}", info.duration.seconds());
+ let mov = tmp(&format!("op1a_{codec:?}.mov"));
+ let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) };
+ export(&p, seq, &ms, &m, &Progress::default()).unwrap();
+ let msrc = filmcraft_codecs::open_bytes("x.mov", std::fs::read(&mov).unwrap().into()).unwrap();
+ for f in [0i64, 5, 13, 23, 7] {
+ let t = FrameRate::FPS_24.tick_of(f) + Tick(1);
+ let a = src.video_frame(FrameRequest::full(t)).unwrap().to_rgba8();
+ let b = msrc.video_frame(FrameRequest::full(t)).unwrap().to_rgba8();
+ assert!(a == b, "{codec:?}: frame {f} differs from the MOV export");
+ }
+ let pcm = src.audio(0, 48_000, 48_000).unwrap();
+ assert!((pcm.peaks()[0] - 0.5).abs() < 0.05, "{codec:?}: tone at -6 dB: {}", pcm.peaks()[0]);
+ }
+}
+
+#[test]
+fn op_atom_writes_one_file_per_essence() {
+ let (p, seq, m) = project();
+ let path = tmp("atom.mxf");
+ let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap();
+ assert_eq!(r.extra_files, opatom_audio_paths(&path, 2));
+ assert!(r.extra_files[0].ends_with("atom_A1.mxf") && r.extra_files[1].ends_with("atom_A2.mxf"));
+ let v = std::fs::read(&path).unwrap();
+ let mv = filmcraft_mxf::open(&v).unwrap();
+ assert_eq!(mv.operational_pattern.name(), "OP-Atom");
+ assert_eq!(mv.tracks.len(), 1);
+ assert_eq!(mv.tracks[0].wrapping, filmcraft_mxf::Wrapping::Clip);
+ assert_eq!(mv.tracks[0].samples.len(), 24);
+ for (k, a) in r.extra_files.iter().enumerate() {
+ let b = std::fs::read(a).unwrap();
+ let ma = filmcraft_mxf::open(&b).unwrap();
+ assert_eq!(ma.operational_pattern.name(), "OP-Atom");
+ let t = &ma.tracks[0];
+ assert_eq!(t.kind, filmcraft_mxf::TrackKind::Sound);
+ assert_eq!(t.track_id, 3 + k as u32);
+ assert_eq!(t.sound.as_ref().unwrap().channels, 1);
+ assert_eq!(t.stored_sample_frames(), 48_000);
+ assert_eq!(t.duration, Some(24), "audio edit rate is the frame rate");
+ assert_eq!(ma.timecode.unwrap().format(), "01:00:00:00");
+ let pcm = ma.read_pcm(&b, 0, 0, 48_000).unwrap();
+ assert!((pcm[0].iter().fold(0f32, |a, s| a.max(s.abs())) - 0.5).abs() < 0.05);
+ }
+}
+
+#[test]
+fn mxf_formats_and_presets() {
+ assert_eq!(Format::from_name("mxf"), Some(Format::MxfOp1a));
+ assert_eq!(Format::from_name("mxf-op1a"), Some(Format::MxfOp1a));
+ assert_eq!(Format::from_name("mxf-opatom"), Some(Format::MxfOpAtom));
+ assert_eq!(Format::from_name(Format::MxfOpAtom.id()), Some(Format::MxfOpAtom));
+ assert_eq!(Format::MxfOp1a.extension(), "mxf");
+ let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "mxf-op1a", "mxfVideoCodec": "proRes"})).unwrap();
+ assert_eq!((s.format, s.video_format()), (Format::MxfOp1a, Format::ProRes));
+ assert_eq!(s.audio_codec(), AudioCodec::Pcm);
+ assert!(s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).format.contains("MXF OP1a (Apple ProRes)"));
+ let names: Vec = builtin_presets().into_iter().filter(|p| p.settings.format.is_mxf()).map(|p| p.name).collect();
+ assert_eq!(names.len(), 4, "{names:?}");
+ assert_eq!(opatom_audio_paths("/a/b/clip.mxf", 2), vec!["/a/b/clip_A1.mxf".to_string(), "/a/b/clip_A2.mxf".to_string()]);
+}
+
+/// ffprobe identifies our files (operational pattern, codec, frame count, duration, timecode);
+/// ffmpeg decodes them to exactly what it decodes from the same essence in a MOV export, and
+/// the PCM is sample-exact against what we wrote.
+#[test]
+fn ffmpeg_oracle_op1a_and_op_atom() {
+ let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("MXF export probe"), filmcraft_testkit::ffmpeg_or_skip("MXF export decode")) else {
+ return;
+ };
+ let (p, seq, m) = project();
+ for codec in MxfVideoCodec::ALL {
+ let path = tmp(&format!("oracle_{codec:?}.mxf"));
+ export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap();
+ let j = ffprobe_json(&ffprobe, &path);
+ assert_eq!(j["format"]["format_name"], "mxf", "{codec:?}");
+ let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default();
+ assert!(op.starts_with("060e2b34.04010101.0d010201.0101"), "{codec:?}: OP1a label {op}");
+ let streams = j["streams"].as_array().unwrap();
+ let v = streams.iter().find(|s| s["codec_type"] == "video").unwrap();
+ let a = streams.iter().find(|s| s["codec_type"] == "audio").unwrap();
+ assert_eq!(v["codec_name"], codec_name(codec), "{codec:?}");
+ assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(320), Some(180)));
+ assert_eq!(v["nb_read_frames"], "24", "{codec:?}");
+ assert_eq!(v["r_frame_rate"], "24/1", "{codec:?}");
+ let tc = j["format"]["tags"]["timecode"].as_str().or(v["tags"]["timecode"].as_str()).unwrap_or_default();
+ assert_eq!(tc, "01:00:00:00", "{codec:?}");
+ let dur: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap();
+ assert!((dur - 1.0).abs() < 0.05, "{codec:?}: {dur}");
+ assert_eq!(a["codec_name"], "pcm_s24le", "{codec:?}");
+ assert_eq!(a["channels"], 2);
+ assert_eq!(a["sample_rate"], "48000");
+ // ffmpeg's decode of the MXF equals its decode of the same essence in MOV
+ let mov = tmp(&format!("oracle_{codec:?}.mov"));
+ let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) };
+ export(&p, seq, &ms, &m, &Progress::default()).unwrap();
+ let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"];
+ let a_mxf = ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw);
+ let a_mov = ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw);
+ assert_eq!(a_mxf.len(), 24 * 320 * 180 * 4, "{codec:?}: 24 frames decoded");
+ assert!(a_mxf == a_mov, "{codec:?}: ffmpeg decodes the MXF and the MOV essence differently");
+ // PCM exact: ffmpeg's samples are what our demuxer reads back
+ let pcm = ffmpeg_raw(&ffmpeg, &path, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
+ let bytes = std::fs::read(&path).unwrap();
+ let mx = filmcraft_mxf::open(&bytes).unwrap();
+ let at = mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
+ let ours = mx.read_pcm(&bytes, at, 0, 48_000).unwrap();
+ assert_eq!(pcm.len(), 48_000 * 2 * 4, "{codec:?}");
+ for (i, c) in pcm.chunks_exact(4).enumerate() {
+ let s = i32::from_le_bytes([c[0], c[1], c[2], c[3]]) >> 8;
+ assert_eq!(s as f32 / 8_388_608.0, ours[i % 2][i / 2], "{codec:?}: sample {i}");
+ }
+ }
+ // OP-Atom: picture file and one mono PCM file per channel
+ let path = tmp("oracle_atom.mxf");
+ let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap();
+ let j = ffprobe_json(&ffprobe, &path);
+ let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default();
+ assert!(op.starts_with("060e2b34.04010102.0d010201.10"), "OP-Atom label {op}");
+ let v = &j["streams"][0];
+ assert_eq!(v["codec_name"], "dnxhd");
+ assert_eq!(v["nb_read_frames"], "24");
+ assert_eq!(j["streams"].as_array().unwrap().len(), 1);
+ let mov = tmp("oracle_atom.mov");
+ let ms = ExportSettings { format: Format::DnxHr, multiplexer: Multiplexer::Mov, ..settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &mov) };
+ export(&p, seq, &ms, &m, &Progress::default()).unwrap();
+ let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"];
+ assert!(ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw) == ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw), "OP-Atom picture decode");
+ let wav = tmp("oracle_atom.wav");
+ let ws = ExportSettings { format: Format::Wav, ..settings(Format::Wav, MxfVideoCodec::Dnxhr, &wav) };
+ export(&p, seq, &ws, &m, &Progress::default()).unwrap();
+ let stereo = ffmpeg_raw(&ffmpeg, &wav, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
+ for (k, a) in r.extra_files.iter().enumerate() {
+ let j = ffprobe_json(&ffprobe, a);
+ let s = &j["streams"][0];
+ assert_eq!((s["codec_name"].as_str(), s["channels"].as_u64(), s["sample_rate"].as_str()), (Some("pcm_s24le"), Some(1), Some("48000")), "{a}");
+ let mono = ffmpeg_raw(&ffmpeg, a, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
+ assert_eq!(mono.len(), 48_000 * 4, "{a}");
+ let want: Vec = stereo.chunks_exact(8).flat_map(|f| f[k * 4..k * 4 + 4].to_vec()).collect();
+ assert!(mono == want, "{a}: channel {k} differs from the WAV export");
+ }
+}
diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs
index c739ec73..e1545fda 100644
--- a/crates/export/src/presets.rs
+++ b/crates/export/src/presets.rs
@@ -5,7 +5,7 @@
use filmcraft_time::FrameRate;
use serde::{Deserialize, Serialize};
-use crate::settings::{AudioCodec, AudioSettings, BitrateMode, Scaling};
+use crate::settings::{AudioCodec, AudioSettings, BitrateMode, MxfVideoCodec, Scaling};
use crate::{ExportSettings, Format};
/// A named set of export settings (`path` and `range` are left empty).
@@ -64,6 +64,20 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
s
}
+fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings {
+ let mut s = ExportSettings { mxf_video_codec: codec, ..mov(format, profile) };
+ match codec {
+ MxfVideoCodec::ProRes => s.prores_profile = profile.into(),
+ MxfVideoCodec::Dnxhr => s.dnx_profile = profile.into(),
+ MxfVideoCodec::H264 => {
+ s.bitrate_kbps = 50_000;
+ s.adaptive_bitrate = Some(0.3);
+ s.bitrate_mode = BitrateMode::Vbr1Pass;
+ }
+ }
+ s
+}
+
fn audio_only(format: Format, bits: u16) -> ExportSettings {
ExportSettings {
format,
@@ -110,6 +124,15 @@ pub fn builtin_presets() -> Vec {
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
+ p("MXF OP1a DNxHR HQ", bc, "MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::Dnxhr, "hq")),
+ p("MXF OP1a ProRes 422 HQ", bc, "MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::ProRes, "hq")),
+ p("MXF OP1a H.264", bc, "MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM", mxf(Format::MxfOp1a, MxfVideoCodec::H264, "")),
+ p(
+ "MXF OP-Atom DNxHR (Avid)",
+ bc,
+ "Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…)",
+ mxf(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, "hq"),
+ ),
p(
"PNG Sequence",
"Image Sequence",
diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs
index 2522600f..90b2b163 100644
--- a/crates/export/src/settings.rs
+++ b/crates/export/src/settings.rs
@@ -94,6 +94,38 @@ pub enum Multiplexer {
Mov,
}
+/// Video codec of an MXF export.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum MxfVideoCodec {
+ /// Avid DNxHR (VC-3, SMPTE ST 2019-4 mapping).
+ #[default]
+ Dnxhr,
+ /// Apple ProRes (RDD 44 mapping).
+ ProRes,
+ /// H.264 long GOP (ST 381-3 byte-stream mapping).
+ H264,
+}
+
+impl MxfVideoCodec {
+ pub const ALL: [MxfVideoCodec; 3] = [MxfVideoCodec::Dnxhr, MxfVideoCodec::ProRes, MxfVideoCodec::H264];
+ pub fn label(self) -> &'static str {
+ match self {
+ MxfVideoCodec::Dnxhr => "Avid DNxHR",
+ MxfVideoCodec::ProRes => "Apple ProRes",
+ MxfVideoCodec::H264 => "H.264",
+ }
+ }
+ /// The export format whose encoder this codec uses.
+ pub fn encoder_format(self) -> Format {
+ match self {
+ MxfVideoCodec::Dnxhr => Format::DnxHr,
+ MxfVideoCodec::ProRes => Format::ProRes,
+ MxfVideoCodec::H264 => Format::H264,
+ }
+ }
+}
+
/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -307,6 +339,11 @@ pub struct Resolved {
}
impl ExportSettings {
+ /// The format whose video encoder runs: the MXF video codec for MXF exports, else the format.
+ pub fn video_format(&self) -> Format {
+ if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format }
+ }
+
/// Whether the output is a numbered image sequence.
pub fn is_image_sequence(&self) -> bool {
matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)
@@ -354,7 +391,7 @@ impl ExportSettings {
/// The audio codec actually used.
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
- (Format::Wav | Format::Aiff, _) => AudioCodec::Pcm,
+ (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
(Format::H264, _) if self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
(_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac,
(_, AudioCodec::Auto) => AudioCodec::Pcm,
@@ -383,7 +420,7 @@ impl ExportSettings {
let secs = duration.seconds().max(0.0);
let fps = r.rate.num as f64 / r.rate.den as f64;
let px = r.width as f64 * r.height as f64;
- let video_bps = match self.format {
+ let video_bps = match self.video_format() {
Format::H264 => r.target_kbps as f64 * 1000.0,
Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
Format::DnxHr => {
@@ -400,7 +437,7 @@ impl ExportSettings {
Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps,
Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps,
Format::Gif => px * 0.6 * 8.0 * fps,
- Format::Wav | Format::Aiff => 0.0,
+ Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
match self.audio_codec() {
@@ -434,7 +471,7 @@ impl ExportSettings {
} else {
let par = self.pixel_aspect.map(|(n, d)| format!("{:.2}", n.max(1) as f64 / d.max(1) as f64)).unwrap_or_else(|| "1.0".into());
let mut v = format!("{}x{} ({par}), {} fps, {}", r.width, r.height, r.rate.label(), self.field_order.label());
- match self.format {
+ match self.video_format() {
Format::H264 => {
v += &format!(
", {} {}, {}",
@@ -480,6 +517,7 @@ impl ExportSettings {
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
Format::H264 => "MP4",
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
+ Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
_ => "",
};
diff --git a/crates/interchange/Cargo.toml b/crates/interchange/Cargo.toml
index 4d6599af..3eecc494 100644
--- a/crates/interchange/Cargo.toml
+++ b/crates/interchange/Cargo.toml
@@ -15,6 +15,7 @@ filmcraft-geom = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
+filmcraft-cfb = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
diff --git a/crates/interchange/README.md b/crates/interchange/README.md
new file mode 100644
index 00000000..681653da
--- /dev/null
+++ b/crates/interchange/README.md
@@ -0,0 +1,56 @@
+# filmcraft-interchange
+
+Timeline interchange for FilmCraft: CMX 3600 EDL, Final Cut Pro 7 XML (xmeml), FCPXML,
+OpenTimelineIO, AAF and OMF import and export, plus Avid ALE. Layer L2: no file I/O; media
+references are strings and audio essence is supplied by the caller (the engine).
+
+## Specifications
+
+Clean-room: written from public specifications only. The AAF SDK, pyaaf2, the OMF Toolkit and
+other implementations were not consulted.
+
+| Format | Document | Edition | Used for |
+|---|---|---|---|
+| AAF | AMWA *AAF Object Specification* | v1.1 (2005) | classes, properties (ids shared with SMPTE ST 377-1 local tags), mob / slot / segment model, sequences and transitions, operation groups, parameters, definitions, descriptors, locators, essence data |
+| AAF | AMWA *AAF Low-Level Container Specification* | v1.0.1 | objects as structured storages, `properties` streams, stored forms, strong / weak reference collections and their index streams, `referenced properties` |
+| AAF | AMWA *AAF Edit Protocol* | v1.0 (AMWA AS-01) | top-level composition, mob chains (composition → master → file → physical source), operation definitions (dissolve, fade to black, audio gain / dissolve), usage codes |
+| AAF | SMPTE RP 224 / RP 210 registers | — | data definition, class and type labels |
+| AAF | Microsoft [MS-CFB] | rev. 10.0 | the container, see [`filmcraft-cfb`](../cfb/README.md) |
+| OMF | Avid *OMF Interchange Specification* | Version 2.0 (1997) | classes (`HEAD`, `CMOB`, `MMOB`, `SMOB`, `MSLT`, `TRKD`, `SEQU`, `SCLP`, `FILL`, `TRAN`, `EFFE`, `ESLT`, `CVAL`, `VVAL`, `TCCP`, `WAVD`, `AIFD`, `WAVE`, `AIFC`, locators), property and type names |
+| OMF | Apple *Bento Specification* | revision 1.0d5 | container label, TOC encoding, objects / properties / types, references |
+| WAVE / AIFF | Microsoft RIFF WAVE; Apple AIFF 1.3 | — | embedded and separate audio files |
+
+The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`).
+
+## AAF and OMF
+
+`comp` holds a format-neutral mob-style model (compositions of slots whose sequences of fillers,
+source clips and overlapping transitions reference media through master and file source mobs).
+`aaf` and `omf` serialise it; both import back into the same model and from there into FilmCraft
+sequences and media. Mapping details (what each FilmCraft feature becomes) are in the `aaf` and
+`omf` module docs. Times are kept in ticks in the model; compositions use the sequence frame rate
+for picture and the sample rate for sound slots, so audio edits are sample-exact.
+
+Embedded / consolidated audio (`essence`): `audio_needs` lists the media ranges an export
+references (per media item or per clip, with handles); the engine decodes or renders them and
+passes `AudioEssence` (embedded PCM or a written file) back in `MediaOptions`.
+
+Not represented: speed changes and frame holds (exported at 100 % with a report entry), nested
+sequences, graphics and synthetic media (gaps), video effects other than transitions, clip
+markers (written to the master mob, so they come back as media markers), OMF video and markers.
+
+## Tests
+
+- `tests/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
+ (sample data and source offsets); separate consolidated files with a video mixdown; OMF round
+ trips with embedded audio, separate AIFF files and breakout; empty sequences; truncation and
+ random corruption never panic.
+- `src/aaf/tests.rs`: written files checked against the required properties of every class
+ written (Header, Identification, Mobs, slots, components, descriptors), weak references
+ resolving into the dictionary, source clips resolving to mobs and slots, the Edit Protocol
+ transition rules and sequence length arithmetic; every stored form round-trips through the
+ container.
+- `src/omf/tests.rs`: the Bento label and TOC, the required properties of every OMF class
+ written, reference resolution, embedded WAVE data, sequence lengths in samples.
diff --git a/crates/interchange/src/aaf/ids.rs b/crates/interchange/src/aaf/ids.rs
new file mode 100644
index 00000000..2b9c76ee
--- /dev/null
+++ b/crates/interchange/src/aaf/ids.rs
@@ -0,0 +1,253 @@
+//! Class ids, property ids and definition ids of the AAF object model subset FilmCraft reads and
+//! writes (AAF Object Specification v1.1, AAF Edit Protocol). Class and data definition AUIDs are
+//! SMPTE labels (RP 224 / RP 210 registers) in AUID byte order; property ids are the AAF / ST 377-1
+//! local tags.
+
+use super::store::Auid;
+
+/// AUID of a SMPTE universal label: the label's last 8 bytes become Data1–Data3 (stored
+/// little-endian), its first 8 bytes Data4.
+pub(crate) const fn ul(b: [u8; 16]) -> Auid {
+ [b[11], b[10], b[9], b[8], b[13], b[12], b[15], b[14], b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]
+}
+
+/// A GUID `{d1-d2-d3-d4}` in AUID byte order.
+pub(crate) const fn guid(d1: u32, d2: u16, d3: u16, d4: [u8; 8]) -> Auid {
+ let a = d1.to_le_bytes();
+ let b = d2.to_le_bytes();
+ let c = d3.to_le_bytes();
+ [a[0], a[1], a[2], a[3], b[0], b[1], c[0], c[1], d4[0], d4[1], d4[2], d4[3], d4[4], d4[5], d4[6], d4[7]]
+}
+
+/// Class AUID: SMPTE label 06.0E.2B.34.02.06.01.01.0D.01.01.01.01.01.xx.00.
+pub(crate) const fn class(xx: u8) -> Auid {
+ ul([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x06, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, xx, 0x00])
+}
+
+/// The root object's class (the storage class of the file's root).
+pub(crate) const ROOT: Auid = guid(0xB3B398A5, 0x1C90, 0x11D4, [0x80, 0x53, 0x08, 0x00, 0x36, 0x21, 0x08, 0x04]);
+/// The meta-dictionary (class 0x25 of the meta-model, 06.0E.2B.34.02.06.01.01.0D.01.01.01.02.25.00.00).
+pub(crate) const META_DICTIONARY: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x06, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x02, 0x25, 0x00, 0x00]);
+
+#[allow(dead_code)]
+pub(crate) mod cls {
+ use super::class;
+ use crate::aaf::store::Auid;
+ pub const COMMENT_MARKER: Auid = class(0x08);
+ pub const FILLER: Auid = class(0x09);
+ pub const OPERATION_GROUP: Auid = class(0x0A);
+ pub const NESTED_SCOPE: Auid = class(0x0B);
+ pub const SELECTOR: Auid = class(0x0E);
+ pub const SEQUENCE: Auid = class(0x0F);
+ pub const SOURCE_CLIP: Auid = class(0x11);
+ pub const TIMECODE: Auid = class(0x14);
+ pub const TRANSITION: Auid = class(0x17);
+ pub const CONTENT_STORAGE: Auid = class(0x18);
+ pub const CONTROL_POINT: Auid = class(0x19);
+ pub const DATA_DEFINITION: Auid = class(0x1B);
+ pub const OPERATION_DEFINITION: Auid = class(0x1C);
+ pub const PARAMETER_DEFINITION: Auid = class(0x1D);
+ pub const CONTAINER_DEFINITION: Auid = class(0x20);
+ pub const INTERPOLATION_DEFINITION: Auid = class(0x21);
+ pub const DICTIONARY: Auid = class(0x22);
+ pub const ESSENCE_DATA: Auid = class(0x23);
+ pub const CDCI_DESCRIPTOR: Auid = class(0x28);
+ pub const TAPE_DESCRIPTOR: Auid = class(0x2E);
+ pub const HEADER: Auid = class(0x2F);
+ pub const IDENTIFICATION: Auid = class(0x30);
+ pub const NETWORK_LOCATOR: Auid = class(0x32);
+ pub const COMPOSITION_MOB: Auid = class(0x35);
+ pub const MASTER_MOB: Auid = class(0x36);
+ pub const SOURCE_MOB: Auid = class(0x37);
+ pub const EVENT_MOB_SLOT: Auid = class(0x39);
+ pub const TIMELINE_MOB_SLOT: Auid = class(0x3B);
+ pub const CONSTANT_VALUE: Auid = class(0x3D);
+ pub const VARYING_VALUE: Auid = class(0x3E);
+ pub const TAGGED_VALUE: Auid = class(0x3F);
+ pub const DESCRIPTIVE_MARKER: Auid = class(0x41);
+ pub const PCM_DESCRIPTOR: Auid = class(0x48);
+ pub const IMPORT_DESCRIPTOR: Auid = class(0x4A);
+ /// Classes with a `Locator` and media properties FilmCraft reads as file descriptors.
+ pub const WAVE_DESCRIPTOR: Auid = class(0x2C);
+ pub const AIFC_DESCRIPTOR: Auid = class(0x26);
+ pub const SOUND_DESCRIPTOR: Auid = class(0x42);
+ pub const RGBA_DESCRIPTOR: Auid = class(0x29);
+ pub const MULTIPLE_DESCRIPTOR: Auid = class(0x44);
+}
+
+/// Property ids.
+#[allow(dead_code)]
+pub(crate) mod pid {
+ // Root
+ pub const ROOT_META_DICTIONARY: u16 = 0x0001;
+ pub const ROOT_HEADER: u16 = 0x0002;
+ // Header
+ pub const BYTE_ORDER: u16 = 0x3B01;
+ pub const LAST_MODIFIED: u16 = 0x3B02;
+ pub const CONTENT: u16 = 0x3B03;
+ pub const DICTIONARY: u16 = 0x3B04;
+ pub const VERSION: u16 = 0x3B05;
+ pub const IDENTIFICATION_LIST: u16 = 0x3B06;
+ pub const OBJECT_MODEL_VERSION: u16 = 0x3B07;
+ pub const OPERATIONAL_PATTERN: u16 = 0x3B09;
+ // Identification
+ pub const COMPANY_NAME: u16 = 0x3C01;
+ pub const PRODUCT_NAME: u16 = 0x3C02;
+ pub const PRODUCT_VERSION_STRING: u16 = 0x3C04;
+ pub const PRODUCT_ID: u16 = 0x3C05;
+ pub const DATE: u16 = 0x3C06;
+ pub const PLATFORM: u16 = 0x3C08;
+ pub const GENERATION_AUID: u16 = 0x3C09;
+ // ContentStorage
+ pub const MOBS: u16 = 0x1901;
+ pub const ESSENCE_DATA: u16 = 0x1902;
+ // Dictionary
+ pub const OPERATION_DEFINITIONS: u16 = 0x2603;
+ pub const PARAMETER_DEFINITIONS: u16 = 0x2604;
+ pub const DATA_DEFINITIONS: u16 = 0x2605;
+ pub const CONTAINER_DEFINITIONS: u16 = 0x2608;
+ pub const INTERPOLATION_DEFINITIONS: u16 = 0x2609;
+ // DefinitionObject
+ pub const IDENTIFICATION: u16 = 0x1B01;
+ pub const NAME: u16 = 0x1B02;
+ pub const DESCRIPTION: u16 = 0x1B03;
+ // OperationDefinition
+ pub const OPDEF_DATA_DEFINITION: u16 = 0x1E01;
+ pub const OPDEF_IS_TIME_WARP: u16 = 0x1E02;
+ pub const OPDEF_NUMBER_INPUTS: u16 = 0x1E07;
+ pub const OPDEF_PARAMETERS_DEFINED: u16 = 0x1E09;
+ // ParameterDefinition
+ pub const PARAMDEF_DISPLAY_UNITS: u16 = 0x1F03;
+ // Mob
+ pub const MOB_ID: u16 = 0x4401;
+ pub const MOB_NAME: u16 = 0x4402;
+ pub const SLOTS: u16 = 0x4403;
+ pub const MOB_LAST_MODIFIED: u16 = 0x4404;
+ pub const MOB_CREATION_TIME: u16 = 0x4405;
+ pub const MOB_USER_COMMENTS: u16 = 0x4406;
+ pub const USAGE_CODE: u16 = 0x4408;
+ // SourceMob
+ pub const ESSENCE_DESCRIPTION: u16 = 0x4701;
+ // MobSlot
+ pub const SLOT_ID: u16 = 0x4801;
+ pub const SLOT_NAME: u16 = 0x4802;
+ pub const SEGMENT: u16 = 0x4803;
+ pub const PHYSICAL_TRACK_NUMBER: u16 = 0x4804;
+ // TimelineMobSlot / EventMobSlot
+ pub const EDIT_RATE: u16 = 0x4B01;
+ pub const ORIGIN: u16 = 0x4B02;
+ pub const EVENT_EDIT_RATE: u16 = 0x4901;
+ // Component
+ pub const DATA_DEFINITION: u16 = 0x0201;
+ pub const LENGTH: u16 = 0x0202;
+ pub const COMPONENT_USER_COMMENTS: u16 = 0x0204;
+ // Sequence
+ pub const COMPONENTS: u16 = 0x1001;
+ // SourceReference / SourceClip
+ pub const SOURCE_ID: u16 = 0x1101;
+ pub const SOURCE_MOB_SLOT_ID: u16 = 0x1102;
+ pub const START_TIME: u16 = 0x1201;
+ // Event
+ pub const POSITION: u16 = 0x0601;
+ pub const COMMENT: u16 = 0x0602;
+ // Timecode
+ pub const TC_START: u16 = 0x1501;
+ pub const TC_FPS: u16 = 0x1502;
+ pub const TC_DROP: u16 = 0x1503;
+ // Transition
+ pub const OPERATION_GROUP: u16 = 0x1801;
+ pub const CUT_POINT: u16 = 0x1802;
+ // OperationGroup
+ pub const OPERATION: u16 = 0x0B01;
+ pub const INPUT_SEGMENTS: u16 = 0x0B02;
+ pub const PARAMETERS: u16 = 0x0B03;
+ // Selector / NestedScope
+ pub const SELECTED: u16 = 0x0F01;
+ pub const NESTED_SLOTS: u16 = 0x0C01;
+ // Parameter / ConstantValue / VaryingValue / ControlPoint
+ pub const PARAMETER_DEFINITION: u16 = 0x4C01;
+ pub const CONSTANT_VALUE: u16 = 0x4D01;
+ pub const INTERPOLATION: u16 = 0x4E01;
+ pub const POINT_LIST: u16 = 0x4E02;
+ pub const CP_VALUE: u16 = 0x1A02;
+ pub const CP_TIME: u16 = 0x1A03;
+ pub const CP_EDIT_HINT: u16 = 0x1A04;
+ // TaggedValue
+ pub const TAG_NAME: u16 = 0x5001;
+ pub const TAG_VALUE: u16 = 0x5003;
+ // EssenceDescriptor / FileDescriptor
+ pub const LOCATOR: u16 = 0x2F01;
+ pub const SAMPLE_RATE: u16 = 0x3001;
+ pub const FILE_LENGTH: u16 = 0x3002;
+ pub const CONTAINER_FORMAT: u16 = 0x3004;
+ pub const STORED_HEIGHT: u16 = 0x3202;
+ pub const STORED_WIDTH: u16 = 0x3203;
+ pub const FRAME_LAYOUT: u16 = 0x320C;
+ pub const VIDEO_LINE_MAP: u16 = 0x320D;
+ pub const IMAGE_ASPECT_RATIO: u16 = 0x320E;
+ pub const COMPONENT_WIDTH: u16 = 0x3301;
+ pub const HORIZONTAL_SUBSAMPLING: u16 = 0x3302;
+ pub const QUANTIZATION_BITS: u16 = 0x3D01;
+ pub const LOCKED: u16 = 0x3D02;
+ pub const AUDIO_SAMPLING_RATE: u16 = 0x3D03;
+ pub const CHANNELS: u16 = 0x3D07;
+ pub const AVERAGE_BPS: u16 = 0x3D09;
+ pub const BLOCK_ALIGN: u16 = 0x3D0A;
+ // NetworkLocator
+ pub const URL_STRING: u16 = 0x4001;
+ // EssenceData
+ pub const ESSENCE_MOB_ID: u16 = 0x2701;
+ pub const ESSENCE_STREAM: u16 = 0x2702;
+}
+
+/// Data definitions (SMPTE RP 224 labels 06.0E.2B.34.04.01.01.01.01.03.02.xx.yy).
+pub(crate) mod ddef {
+ use super::{guid, ul};
+ use crate::aaf::store::Auid;
+ pub const PICTURE: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00]);
+ pub const SOUND: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00]);
+ pub const TIMECODE: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00]);
+ pub const DESCRIPTIVE_METADATA: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x10, 0x00, 0x00, 0x00]);
+ /// Pre-SMPTE ("legacy") data definitions written by older applications.
+ pub const LEGACY_PICTURE: Auid = guid(0x6F3C8CE1, 0x6CEF, 0x11D2, [0x80, 0x7D, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
+ pub const LEGACY_SOUND: Auid = guid(0x78E1EBE1, 0x6CEF, 0x11D2, [0x80, 0x7D, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
+ pub const LEGACY_TIMECODE: Auid = guid(0x7F275E81, 0x77E5, 0x11D2, [0x80, 0x7F, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
+}
+
+/// Operation, parameter, interpolation and container definitions.
+pub(crate) mod def {
+ use super::guid;
+ use crate::aaf::store::Auid;
+ const AV: [u8; 8] = [0x8A, 0x09, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
+ pub const VIDEO_DISSOLVE: Auid = guid(0x0C3BEA41, 0xFC05, 0x11D2, AV);
+ pub const VIDEO_FADE_TO_BLACK: Auid = guid(0x0C3BEA40, 0xFC05, 0x11D2, AV);
+ pub const SMPTE_VIDEO_WIPE: Auid = guid(0x0C3BEA43, 0xFC05, 0x11D2, AV);
+ pub const MONO_AUDIO_DISSOLVE: Auid = guid(0x0C3BEA44, 0xFC05, 0x11D2, AV);
+ const AG: [u8; 8] = [0x8A, 0x38, 0x00, 0x50, 0x04, 0x0E, 0xF7, 0xD2];
+ pub const MONO_AUDIO_GAIN: Auid = guid(0x9D2EA893, 0x0968, 0x11D3, AG);
+ const PD: [u8; 8] = [0x8A, 0x4C, 0x00, 0x50, 0x04, 0x0E, 0xF7, 0xD2];
+ pub const PARAM_LEVEL: Auid = guid(0xE4962320, 0x2267, 0x11D3, PD);
+ pub const PARAM_AMPLITUDE: Auid = guid(0xE4962321, 0x2267, 0x11D3, PD);
+ const IP: [u8; 8] = [0x80, 0xA9, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
+ pub const INTERP_LINEAR: Auid = guid(0x5B6C85A4, 0x0EDE, 0x11D3, IP);
+ pub const INTERP_CONSTANT: Auid = guid(0x5B6C85A5, 0x0EDE, 0x11D3, IP);
+ const CD: [u8; 8] = [0x80, 0x9B, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
+ pub const CONTAINER_AAF: Auid = guid(0x4313B571, 0xD8BA, 0x11D2, CD);
+ pub const CONTAINER_EXTERNAL: Auid = guid(0x4313B572, 0xD8BA, 0x11D2, CD);
+ /// Type ids of indirect values (SMPTE RP 210 type labels).
+ pub const TYPE_RATIONAL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x01, 0x04, 0x01, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
+ pub const TYPE_STRING: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x01, 0x04, 0x01, 0x01, 0x01, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]);
+ /// Edit Protocol operational pattern and mob usage codes.
+ pub const OP_EDIT_PROTOCOL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x05, 0x0D, 0x01, 0x12, 0x01, 0x01, 0x00, 0x00, 0x00]);
+ pub const USAGE_TOP_LEVEL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x05, 0x0D, 0x01, 0x12, 0x01, 0x02, 0x03, 0x00, 0x00]);
+}
+
+/// Weak reference target paths (pids from the root object).
+pub(crate) mod path {
+ use super::pid;
+ pub const DATA_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::DATA_DEFINITIONS];
+ pub const OPERATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::OPERATION_DEFINITIONS];
+ pub const PARAMETER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::PARAMETER_DEFINITIONS];
+ pub const INTERPOLATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::INTERPOLATION_DEFINITIONS];
+ pub const CONTAINER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::CONTAINER_DEFINITIONS];
+}
diff --git a/crates/interchange/src/aaf/mod.rs b/crates/interchange/src/aaf/mod.rs
new file mode 100644
index 00000000..5f563072
--- /dev/null
+++ b/crates/interchange/src/aaf/mod.rs
@@ -0,0 +1,84 @@
+//! AAF (Advanced Authoring Format) import and export, following the AAF Edit Protocol.
+//!
+//! Written from the public AMWA specifications: *AAF Object Specification v1.1*, *AAF Low-Level
+//! Container Specification v1.0.1* (structured storage mapping, see [`store`]) and *AAF Edit
+//! Protocol v1.0*; the container is Microsoft's Compound File Binary format ([`filmcraft_cfb`]).
+//!
+//! The exported file holds one top-level **CompositionMob** (Edit Protocol usage code
+//! "top level") with
+//!
+//! - a timecode slot (sequence start timecode, rounded rate, drop frame),
+//! - one timeline slot per video track (picture) and per audio track (sound; one per channel when
+//! broken out to mono), each a **Sequence** of **Filler**s, **SourceClip**s and **Transition**s
+//! (Cross Dissolve → `VideoDissolve_2`, Dip to Black → `VideoFadeToBlack`, audio crossfades →
+//! `MonoAudioDissolve`; the FilmCraft effect id is kept in a tagged value),
+//! - clip volume as a `MonoAudioGain` **OperationGroup** around the source clip with an
+//! `Amplitude` **ConstantValue** or **VaryingValue** (keyframes as control points),
+//! - sequence markers as **CommentMarker**s in an event slot;
+//!
+//! and for every media item a **MasterMob**, a file **SourceMob** per essence (a CDCI or PCM
+//! descriptor with a network locator to the file, or `EssenceData` holding embedded PCM) and a
+//! tape **SourceMob** carrying the media's start timecode. Clip and media markers become comment
+//! markers on the master mob.
+//!
+//! Import reads the same structures (and the common variations: nested sequences, selectors,
+//! operation groups around clips, legacy data definitions, essence groups) into FilmCraft
+//! sequences, media items (linked by path; embedded audio is returned as WAV files for the
+//! caller to write) and markers.
+
+mod ids;
+mod read;
+pub(crate) mod store;
+mod write;
+
+use filmcraft_project::{ItemId, Project};
+use serde::{Deserialize, Serialize};
+
+use crate::comp::{self, ExtractedMedia};
+use crate::essence::MediaOptions;
+use crate::{ImportOptions, Imported, Report, Result};
+
+/// AAF export options (Premiere's AAF export dialog maps onto [`MediaOptions`]).
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct AafOptions {
+ /// Composition name (default: the sequence name).
+ pub name: Option,
+ pub media: MediaOptions,
+ /// Write 512-byte sectors (compound file version 3) instead of 4096-byte sectors.
+ pub small_sectors: bool,
+}
+
+/// Export `sequence` as an AAF file.
+pub fn export(project: &Project, sequence: ItemId, opts: &AafOptions) -> Result<(Vec, Report)> {
+ let mut report = Report::default();
+ let name = opts.name.clone().or_else(|| project.item(sequence).map(|i| i.name.clone())).unwrap_or_else(|| "Sequence".into());
+ let doc = comp::from_project(project, sequence, &name, &opts.media, &mut report)?;
+ let version = if opts.small_sectors { filmcraft_cfb::Version::V3 } else { filmcraft_cfb::Version::V4 };
+ let bytes = write::write(&doc, version, &mut report)?;
+ Ok((bytes, report))
+}
+
+/// Import an AAF file: its top-level compositions become sequences. Embedded audio is returned
+/// as WAV files whose paths are the paths of the imported media items.
+pub fn import(bytes: &[u8], opts: &ImportOptions) -> Result<(Imported, Vec, Report)> {
+ let mut report = Report::default();
+ let doc = read::read(bytes, &mut report)?;
+ let (imported, extracted) = comp::to_project(doc, opts, &mut report)?;
+ Ok((imported, extracted, report))
+}
+
+/// Whether `bytes` look like an AAF file (a compound file whose root has the AAF root class or a
+/// `Header-2` storage).
+pub fn sniff(bytes: &[u8]) -> bool {
+ if !filmcraft_cfb::sniff(bytes) {
+ return false;
+ }
+ match filmcraft_cfb::CompoundFile::open(bytes) {
+ Ok(cf) => cf.root().clsid == ids::ROOT || cf.find("Header-2").is_ok(),
+ Err(_) => false,
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/interchange/src/aaf/read.rs b/crates/interchange/src/aaf/read.rs
new file mode 100644
index 00000000..92b20e60
--- /dev/null
+++ b/crates/interchange/src/aaf/read.rs
@@ -0,0 +1,575 @@
+//! AAF objects → composition model.
+
+use std::collections::{HashMap, HashSet};
+
+use filmcraft_time::{FrameRate, Tick};
+
+use super::ids::{cls, ddef, def, pid};
+use super::store::{self, Auid, Obj, Value, rational_of, utf16_of};
+use crate::comp::{CClip, CItem, CKind, CMarker, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks};
+use crate::{Error, Report, Result};
+
+type MobId = [u8; 32];
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum DataKind {
+ Picture,
+ Sound,
+ Timecode,
+ Other,
+}
+
+fn data_kind(o: &Obj) -> DataKind {
+ match o.weak_key(pid::DATA_DEFINITION).and_then(|k| ::try_from(k).ok()) {
+ Some(k) if k == ddef::PICTURE || k == ddef::LEGACY_PICTURE => DataKind::Picture,
+ Some(k) if k == ddef::SOUND || k == ddef::LEGACY_SOUND => DataKind::Sound,
+ Some(k) if k == ddef::TIMECODE || k == ddef::LEGACY_TIMECODE => DataKind::Timecode,
+ Some(_) => DataKind::Other,
+ None => DataKind::Other,
+ }
+}
+
+fn len_of(o: &Obj) -> i64 {
+ o.i64(pid::LENGTH).unwrap_or(0).max(0)
+}
+
+/// The value of an indirect property: (type id, bytes).
+fn indirect(d: &[u8]) -> Option<(Auid, &[u8])> {
+ if d.len() >= 17 && (d[0] == 0x4C || d[0] == 0x42) {
+ return Some((d[1..17].try_into().ok()?, &d[17..]));
+ }
+ if d.len() >= 16 {
+ return Some((d[..16].try_into().ok()?, &d[16..]));
+ }
+ None
+}
+
+fn tagged_values(o: &Obj, p: u16) -> Vec<(String, String)> {
+ o.objs(p)
+ .iter()
+ .filter_map(|t| {
+ let name = t.string(pid::TAG_NAME)?;
+ let (ty, v) = indirect(t.data(pid::TAG_VALUE)?)?;
+ let value = if ty == def::TYPE_STRING || v.len() % 2 == 0 { utf16_of(v) } else { String::new() };
+ Some((name, value))
+ })
+ .collect()
+}
+
+fn indirect_f64(d: &[u8]) -> Option {
+ let (_, v) = indirect(d)?;
+ match v.len() {
+ 8 => rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64),
+ 4 => Some(i32::from_le_bytes(v.try_into().ok()?) as f64),
+ _ => None,
+ }
+}
+
+struct R<'a> {
+ report: &'a mut Report,
+ mobs: HashMap,
+ essence: HashMap,
+ doc: Document,
+ /// (file mob id, slot id, master key) → source index
+ sources: HashMap<(MobId, u32, String), usize>,
+}
+
+pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result {
+ let root = store::read(bytes).map_err(|e| Error::Parse { format: "AAF", message: e })?;
+ let header = root.strong(pid::ROOT_HEADER).ok_or_else(|| Error::Parse { format: "AAF", message: "no header object".into() })?;
+ let content = header.strong(pid::CONTENT).ok_or_else(|| Error::Parse { format: "AAF", message: "no content storage".into() })?;
+ let mut mobs = HashMap::new();
+ for m in content.objs(pid::MOBS) {
+ if let Some(id) = m.mob_id(pid::MOB_ID) {
+ mobs.insert(id, m);
+ }
+ }
+ let mut essence = HashMap::new();
+ for e in content.objs(pid::ESSENCE_DATA) {
+ if let (Some(id), Some(d)) = (e.mob_id(pid::ESSENCE_MOB_ID), e.stream(pid::ESSENCE_STREAM)) {
+ essence.insert(id, d);
+ }
+ }
+ let mut r = R { report, mobs, essence, doc: Document::default(), sources: HashMap::new() };
+ // top-level compositions: not referenced by another composition (usage code wins when present)
+ let comps: Vec<&Obj> = content.objs(pid::MOBS).iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect();
+ let mut referenced = HashSet::new();
+ for c in &comps {
+ collect_refs(c, &mut referenced);
+ }
+ let tagged_top: Vec<&Obj> = comps.iter().copied().filter(|c| c.auid(pid::USAGE_CODE) == Some(def::USAGE_TOP_LEVEL)).collect();
+ let top: Vec<&Obj> = if !tagged_top.is_empty() {
+ tagged_top
+ } else {
+ comps.iter().copied().filter(|c| c.mob_id(pid::MOB_ID).is_none_or(|id| !referenced.contains(&id))).collect()
+ };
+ for c in top {
+ let comp = r.composition(c);
+ r.doc.compositions.push(comp);
+ }
+ if r.doc.compositions.is_empty() {
+ return Err(Error::Empty);
+ }
+ Ok(r.doc)
+}
+
+fn collect_refs(o: &Obj, out: &mut HashSet) {
+ if o.class == cls::SOURCE_CLIP
+ && let Some(id) = o.mob_id(pid::SOURCE_ID)
+ {
+ out.insert(id);
+ }
+ for (_, v) in &o.props {
+ match v {
+ Value::Strong(c) => collect_refs(c, out),
+ Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| collect_refs(c, out)),
+ _ => {}
+ }
+ }
+}
+
+fn rate_of(o: &Obj, p: u16) -> Option<(i64, i64)> {
+ o.rational(p).filter(|r| r.0 > 0 && r.1 > 0)
+}
+
+impl R<'_> {
+ fn composition(&mut self, c: &Obj) -> Composition {
+ let name = c.string(pid::MOB_NAME).unwrap_or_else(|| "Sequence".into());
+ let slots = c.objs(pid::SLOTS);
+ let pic_rate = slots
+ .iter()
+ .filter(|s| s.class == cls::TIMELINE_MOB_SLOT)
+ .find(|s| s.strong(pid::SEGMENT).is_some_and(|g| matches!(data_kind(g), DataKind::Picture | DataKind::Timecode)))
+ .and_then(|s| rate_of(s, pid::EDIT_RATE))
+ .or_else(|| slots.iter().find_map(|s| rate_of(s, pid::EDIT_RATE)))
+ .unwrap_or((25, 1));
+ let rate = FrameRate::new(pic_rate.0, pic_rate.1);
+ let tags = tagged_values(c, pid::MOB_USER_COMMENTS);
+ let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
+ let (mut width, mut height) = tag("FilmCraft Frame Size")
+ .and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?))))
+ .unwrap_or((0, 0));
+ let mut sample_rate = tag("FilmCraft Audio Sample Rate").and_then(|s| s.parse().ok()).unwrap_or(0u32);
+ let mut comp =
+ Composition { name, rate, sample_rate: 48_000, width: 1920, height: 1080, start_tc: 0, drop: false, tracks: Vec::new(), markers: Vec::new() };
+ let (mut nv, mut na) = (0, 0);
+ for s in slots {
+ let Some(seg) = s.strong(pid::SEGMENT) else { continue };
+ if s.class == cls::EVENT_MOB_SLOT {
+ let er = rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(pic_rate);
+ comp.markers.extend(markers_of(seg, er));
+ continue;
+ }
+ let er = rate_of(s, pid::EDIT_RATE).unwrap_or(pic_rate);
+ let origin = s.i64(pid::ORIGIN).unwrap_or(0);
+ match data_kind(seg) {
+ DataKind::Timecode => {
+ if let Some(tc) = find_class(seg, cls::TIMECODE) {
+ comp.start_tc = tc.i64(pid::TC_START).unwrap_or(0);
+ comp.drop = tc.u8(pid::TC_DROP).unwrap_or(0) != 0;
+ }
+ }
+ kind @ (DataKind::Picture | DataKind::Sound) => {
+ let ckind = if kind == DataKind::Picture { CKind::Picture } else { CKind::Sound };
+ if ckind == CKind::Sound && sample_rate == 0 && er.1 == 1 && er.0 >= 8000 {
+ sample_rate = er.0 as u32;
+ }
+ let mut items = self.segment(seg, er, ckind, 0);
+ if origin > 0 {
+ // material before the origin is not shown
+ trim_front(&mut items, units_to_ticks(origin, er.0, er.1));
+ }
+ let number = s.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(0);
+ let name = s.string(pid::SLOT_NAME).unwrap_or_default();
+ let channels = if ckind == CKind::Sound {
+ items
+ .iter()
+ .filter_map(|i| match i {
+ CItem::Clip(c) => Some(self.doc.sources[c.source].channels),
+ _ => None,
+ })
+ .max()
+ .unwrap_or(1)
+ .min(6)
+ } else {
+ 0
+ };
+ let n = if ckind == CKind::Picture {
+ nv += 1;
+ nv
+ } else {
+ na += 1;
+ na
+ };
+ comp.tracks.push(CTrack { kind: ckind, name, number: if number > 0 { number } else { n }, channels, items });
+ }
+ DataKind::Other => self.report.info("a slot of an unsupported data kind was skipped"),
+ }
+ }
+ if width == 0 {
+ let pic = self.doc.sources.iter().find(|s| s.kind == CKind::Picture && s.width > 0);
+ (width, height) = pic.map_or((1920, 1080), |p| (p.width, p.height));
+ }
+ comp.width = width;
+ comp.height = height;
+ if sample_rate > 0 {
+ comp.sample_rate = sample_rate;
+ }
+ comp
+ }
+
+ /// Items of a segment (a sequence or a single component).
+ fn segment(&mut self, seg: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec {
+ if depth > 32 {
+ self.report.warn("segments nested too deeply were skipped");
+ return vec![CItem::Filler(units_to_ticks(len_of(seg), rate.0, rate.1))];
+ }
+ if seg.class == cls::SEQUENCE {
+ let mut out = Vec::new();
+ for c in seg.objs(pid::COMPONENTS) {
+ out.extend(self.component(c, rate, kind, depth + 1));
+ }
+ return out;
+ }
+ self.component(seg, rate, kind, depth + 1)
+ }
+
+ fn component(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec {
+ let len = units_to_ticks(len_of(c), rate.0, rate.1);
+ match c.class {
+ x if x == cls::FILLER => vec![CItem::Filler(len)],
+ x if x == cls::SOURCE_CLIP => vec![self.clip(c, rate, kind, None)],
+ x if x == cls::SEQUENCE => self.segment(c, rate, kind, depth),
+ x if x == cls::TRANSITION => {
+ let cut = units_to_ticks(c.i64(pid::CUT_POINT).unwrap_or(0), rate.0, rate.1);
+ let og = c.strong(pid::OPERATION_GROUP);
+ let effect = og
+ .and_then(|o| tagged_values(o, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "FilmCraft Effect").map(|(_, v)| v))
+ .unwrap_or_else(|| {
+ let op = og.and_then(|o| o.weak_key(pid::OPERATION)).and_then(|k| ::try_from(k).ok());
+ match (kind, op) {
+ (CKind::Sound, _) => "constant_power".into(),
+ (_, Some(o)) if o == def::VIDEO_FADE_TO_BLACK => "dip_to_black".into(),
+ (_, Some(o)) if o == def::SMPTE_VIDEO_WIPE => "wipe".into(),
+ _ => "cross_dissolve".into(),
+ }
+ });
+ vec![CItem::Transition(CTransition { len, cut: cut.clamp(Tick::ZERO, len), effect })]
+ }
+ x if x == cls::OPERATION_GROUP => {
+ let op = c.weak_key(pid::OPERATION).and_then(|k| ::try_from(k).ok());
+ let inputs = c.objs(pid::INPUT_SEGMENTS);
+ let Some(input) = inputs.first() else {
+ self.report.info("an effect without input was imported as a gap");
+ return vec![CItem::Filler(len)];
+ };
+ let gain = if op == Some(def::MONO_AUDIO_GAIN) {
+ self.gain(c, len, rate)
+ } else {
+ self.report.warn("effects other than audio gain are not imported (their first input is used)");
+ None
+ };
+ let mut items = self.segment(input, rate, kind, depth);
+ // the operation's length rules
+ fit(&mut items, len);
+ if let Some(g) = gain {
+ match items.as_mut_slice() {
+ [CItem::Clip(cl)] => cl.gain = Some(g),
+ _ => self.report.warn("audio gain around several clips was not imported"),
+ }
+ }
+ items
+ }
+ x if x == cls::SELECTOR => match c.strong(pid::SELECTED) {
+ Some(s) => {
+ let mut items = self.segment(s, rate, kind, depth);
+ fit(&mut items, len);
+ items
+ }
+ None => vec![CItem::Filler(len)],
+ },
+ x if x == cls::NESTED_SCOPE => match c.objs(pid::NESTED_SLOTS).last() {
+ Some(s) => {
+ let mut items = self.segment(s, rate, kind, depth);
+ fit(&mut items, len);
+ items
+ }
+ None => vec![CItem::Filler(len)],
+ },
+ _ => {
+ self.report.info("an unsupported component was imported as a gap");
+ vec![CItem::Filler(len)]
+ }
+ }
+ }
+
+ fn gain(&mut self, og: &Obj, len: Tick, _rate: (i64, i64)) -> Option {
+ let p = og
+ .objs(pid::PARAMETERS)
+ .iter()
+ .find(|p| p.auid(pid::PARAMETER_DEFINITION) == Some(def::PARAM_AMPLITUDE))
+ .or_else(|| og.objs(pid::PARAMETERS).first())?;
+ if p.class == cls::CONSTANT_VALUE {
+ return p.data(pid::CONSTANT_VALUE).and_then(indirect_f64).map(Gain::Constant);
+ }
+ if p.class == cls::VARYING_VALUE {
+ let linear = p.weak_key(pid::INTERPOLATION).and_then(|k| ::try_from(k).ok()) != Some(def::INTERP_CONSTANT);
+ let points: Vec<(Tick, f64)> = p
+ .objs(pid::POINT_LIST)
+ .iter()
+ .filter_map(|cp| {
+ let (n, d) = cp.rational(pid::CP_TIME).filter(|r| r.1 != 0)?;
+ let v = cp.data(pid::CP_VALUE).and_then(indirect_f64)?;
+ Some((Tick((len.0 as i128 * n as i128 / d as i128).clamp(-(1i128 << 56), 1i128 << 56) as i64), v))
+ })
+ .collect();
+ return (!points.is_empty()).then_some(Gain::Varying { linear, points });
+ }
+ None
+ }
+
+ fn clip(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, master_key: Option) -> CItem {
+ let len = units_to_ticks(len_of(c), rate.0, rate.1);
+ let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
+ let name = tagged_values(c, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "Clip Name").map(|(_, v)| v).unwrap_or_default();
+ let (Some(id), Some(slot)) = (c.mob_id(pid::SOURCE_ID), c.u32(pid::SOURCE_MOB_SLOT_ID)) else { return CItem::Filler(len) };
+ if id == [0; 32] {
+ return CItem::Filler(len);
+ }
+ match self.resolve(id, slot, start, kind, master_key, 0) {
+ Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }),
+ None => CItem::Filler(len),
+ }
+ }
+
+ /// Follow a source reference to a file source mob: (source index, file time).
+ fn resolve(&mut self, id: MobId, slot_id: u32, start: Tick, kind: CKind, master_key: Option, depth: usize) -> Option<(usize, Tick)> {
+ if depth > 16 {
+ return None;
+ }
+ let Some(mob) = self.mobs.get(&id).copied() else {
+ self.report.warn("a clip references a mob that is not in the file (imported as a gap)");
+ return None;
+ };
+ let slot = mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot_id))?;
+ let rate = rate_of(slot, pid::EDIT_RATE).unwrap_or((25, 1));
+ let seg = slot.strong(pid::SEGMENT)?;
+ if mob.class == cls::COMPOSITION_MOB {
+ self.report.warn("nested compositions are not supported (imported as gaps)");
+ return None;
+ }
+ if mob.class == cls::MASTER_MOB {
+ let key = master_key.unwrap_or_else(|| hex(&id));
+ let clip = first_clip(seg)?;
+ let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
+ let next = clip.mob_id(pid::SOURCE_ID)?;
+ let next_slot = clip.u32(pid::SOURCE_MOB_SLOT_ID)?;
+ if next == [0; 32] {
+ return None;
+ }
+ let markers = mob
+ .objs(pid::SLOTS)
+ .iter()
+ .filter(|s| s.class == cls::EVENT_MOB_SLOT)
+ .flat_map(|s| s.strong(pid::SEGMENT).map(|g| markers_of(g, rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(rate))).unwrap_or_default())
+ .collect::>();
+ let r = self.resolve(next, next_slot, start + inner, kind, Some(key), depth + 1)?;
+ let src = &mut self.doc.sources[r.0];
+ if src.markers.is_empty() {
+ src.markers = markers;
+ }
+ if src.name.is_empty()
+ && let Some(n) = mob.string(pid::MOB_NAME)
+ {
+ src.name = n;
+ }
+ return Some(r);
+ }
+ // a source mob: file (has a file descriptor) or physical (tape / import)
+ let desc = mob.strong(pid::ESSENCE_DESCRIPTION);
+ let is_file = desc.is_some_and(|d| d.get(pid::SAMPLE_RATE).is_some() || d.get(pid::LOCATOR).is_some() || self.essence.contains_key(&id));
+ if !is_file {
+ return None;
+ }
+ let key = master_key.unwrap_or_else(|| hex(&id));
+ let skey = (id, slot_id, key.clone());
+ if let Some(&i) = self.sources.get(&skey) {
+ return Some((i, start));
+ }
+ let d = desc?;
+ let seg_kind = match data_kind(seg) {
+ DataKind::Picture => CKind::Picture,
+ DataKind::Sound => CKind::Sound,
+ _ => kind,
+ };
+ let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| crate::common::file_url_to_path(&u));
+ let file_rate = rate_of(d, pid::SAMPLE_RATE).unwrap_or(rate);
+ let length =
+ d.i64(pid::FILE_LENGTH).map(|l| units_to_ticks(l, file_rate.0, file_rate.1)).unwrap_or_else(|| units_to_ticks(len_of(seg), rate.0, rate.1));
+ let sound_slots = mob.objs(pid::SLOTS).iter().filter(|s| s.strong(pid::SEGMENT).is_some_and(|g| data_kind(g) == DataKind::Sound)).count();
+ let file_channels = d.u32(pid::CHANNELS).unwrap_or(1).max(1);
+ let channel = (seg_kind == CKind::Sound && file_channels > 1 && sound_slots > 1)
+ .then(|| slot.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(slot_id).saturating_sub(1).min(file_channels - 1));
+ let mut layout = d.u8(pid::FRAME_LAYOUT).unwrap_or(0);
+ if layout > 4 {
+ layout = 0;
+ }
+ let stored_h = d.u32(pid::STORED_HEIGHT).unwrap_or(0);
+ let fr = FrameRate::new(file_rate.0, file_rate.1);
+ // timecode from the physical source mob
+ let tc = first_clip(seg).and_then(|clip| {
+ let tid = clip.mob_id(pid::SOURCE_ID)?;
+ let tape = self.mobs.get(&tid)?;
+ let tslot = tape.objs(pid::SLOTS).iter().find_map(|s| {
+ let g = s.strong(pid::SEGMENT)?;
+ let t = find_class(g, cls::TIMECODE)?;
+ Some((t.i64(pid::TC_START).unwrap_or(0), t.u16(pid::TC_FPS).unwrap_or(25).max(1) as i64, rate_of(s, pid::EDIT_RATE)))
+ })?;
+ let off = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
+ let tcr = tslot.2.map(|r| FrameRate::new(r.0, r.1)).unwrap_or_else(|| FrameRate::new(tslot.1, 1));
+ Some((tslot.0 + tcr.frame_at(off), tcr))
+ });
+ let mut s = Source {
+ key,
+ name: mob.string(pid::MOB_NAME).unwrap_or_default(),
+ kind: seg_kind,
+ path,
+ channel,
+ channels: if channel.is_some() { 1 } else { file_channels },
+ file_channels,
+ width: d.u32(pid::STORED_WIDTH).unwrap_or(0),
+ height: if matches!(layout, 1 | 3) { stored_h * 2 } else { stored_h },
+ frame_rate: if seg_kind == CKind::Picture { fr } else { FrameRate::FPS_25 },
+ sample_rate: d.rational(pid::AUDIO_SAMPLING_RATE).filter(|r| r.1 > 0 && r.0 > 0).map(|r| (r.0 / r.1) as u32).unwrap_or(file_rate.0 as u32),
+ bits: d.u32(pid::QUANTIZATION_BITS).unwrap_or(16).clamp(8, 32) as u16,
+ length,
+ offset: Tick::ZERO,
+ start_tc: tc.map(|t| t.0),
+ tc_rate: tc.map(|t| t.1).unwrap_or(fr),
+ embedded: None,
+ markers: Vec::new(),
+ };
+ if s.kind == CKind::Sound {
+ s.frame_rate = FrameRate::FPS_25;
+ if let Some(data) = self.essence.get(&id) {
+ s.embedded = Some(data.to_vec());
+ // embedded essence may be a whole WAVE / AIFF file
+ if let Some((pcm, ch, sr, bits)) = crate::wav::parse_wav(data) {
+ s.embedded = Some(pcm);
+ s.file_channels = ch as u32;
+ s.channels = if s.channel.is_some() { 1 } else { ch as u32 };
+ s.sample_rate = sr;
+ s.bits = bits;
+ }
+ }
+ }
+ if s.kind == CKind::Picture && s.width == 0 {
+ s.width = 1920;
+ s.height = 1080;
+ }
+ let i = self.doc.sources.len();
+ self.doc.sources.push(s);
+ self.sources.insert(skey, i);
+ Some((i, start))
+ }
+}
+
+/// Drop `t` from the front of a track's items (slot origin).
+fn trim_front(items: &mut Vec, mut t: Tick) {
+ while t > Tick::ZERO && !items.is_empty() {
+ let l = items[0].len();
+ if matches!(items[0], CItem::Transition(_)) {
+ items.remove(0);
+ continue;
+ }
+ if l <= t {
+ items.remove(0);
+ t -= l;
+ } else {
+ match &mut items[0] {
+ CItem::Filler(x) => *x -= t,
+ CItem::Clip(c) => {
+ c.len -= t;
+ c.start += t;
+ }
+ CItem::Transition(_) => {}
+ }
+ t = Tick::ZERO;
+ }
+ }
+}
+
+/// Make the items exactly `len` long (an operation group / selector is as long as itself).
+fn fit(items: &mut Vec, len: Tick) {
+ let total = Tick(items.iter().fold(0i64, |a, i| if matches!(i, CItem::Transition(_)) { a.saturating_sub(i.len().0) } else { a.saturating_add(i.len().0) }));
+ if total == len || items.is_empty() {
+ if items.is_empty() {
+ items.push(CItem::Filler(len));
+ }
+ return;
+ }
+ if total < len {
+ items.push(CItem::Filler(len - total));
+ return;
+ }
+ let mut excess = total - len;
+ while excess > Tick::ZERO {
+ match items.last_mut() {
+ Some(CItem::Filler(x)) | Some(CItem::Clip(CClip { len: x, .. })) if *x > excess => {
+ *x -= excess;
+ excess = Tick::ZERO;
+ }
+ Some(CItem::Transition(_)) => {
+ items.pop();
+ }
+ Some(other) => {
+ excess -= other.len();
+ items.pop();
+ }
+ None => break,
+ }
+ }
+}
+
+fn first_clip(seg: &Obj) -> Option<&Obj> {
+ if seg.class == cls::SOURCE_CLIP {
+ return Some(seg);
+ }
+ if seg.class == cls::SEQUENCE {
+ return seg.objs(pid::COMPONENTS).iter().find_map(first_clip);
+ }
+ // essence group (choices 0x0501) / operation group inputs
+ seg.objs(0x0501).iter().find_map(first_clip).or_else(|| seg.objs(pid::INPUT_SEGMENTS).iter().find_map(first_clip))
+}
+
+fn find_class(seg: &Obj, class: Auid) -> Option<&Obj> {
+ if seg.class == class {
+ return Some(seg);
+ }
+ seg.objs(pid::COMPONENTS).iter().find_map(|c| find_class(c, class))
+}
+
+fn markers_of(seg: &Obj, rate: (i64, i64)) -> Vec {
+ let events: Vec<&Obj> = if seg.class == cls::SEQUENCE { seg.objs(pid::COMPONENTS).iter().collect() } else { vec![seg] };
+ events
+ .into_iter()
+ .filter(|e| e.class == cls::COMMENT_MARKER || e.class == cls::DESCRIPTIVE_MARKER || e.get(pid::POSITION).is_some())
+ .map(|e| {
+ let tags = tagged_values(e, pid::COMPONENT_USER_COMMENTS);
+ let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
+ let comment = e.string(pid::COMMENT).unwrap_or_default();
+ let start = units_to_ticks(e.i64(pid::POSITION).unwrap_or(0), rate.0, rate.1);
+ CMarker {
+ start,
+ duration: units_to_ticks(e.i64(pid::LENGTH).unwrap_or(0).max(0), rate.0, rate.1),
+ name: tag("Name").unwrap_or_else(|| comment.clone()),
+ comment: tag("Comment").unwrap_or_else(|| if tags.is_empty() { String::new() } else { comment.clone() }),
+ color: tag("Color"),
+ }
+ })
+ .collect()
+}
+
+fn hex(b: &[u8]) -> String {
+ b.iter().map(|x| format!("{x:02x}")).collect()
+}
diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs
new file mode 100644
index 00000000..92c424af
--- /dev/null
+++ b/crates/interchange/src/aaf/store.rs
@@ -0,0 +1,487 @@
+//! AAF objects in structured storage (the AAF Low-Level Container Specification): every object
+//! is a storage whose class id is the object's class AUID, holding a `properties` stream; strong
+//! references are child storages, collections have an index stream, weak references name a key
+//! in a target collection through the root's `referenced properties` table.
+//!
+//! `properties` stream (little-endian):
+//!
+//! ```text
+//! u8 byte order (0x4C 'L') u8 format version (0x20) u16 entry count
+//! entry count × { u16 pid, u16 stored form, u16 value length } the values, in entry order
+//! ```
+//!
+//! | stored form | value |
+//! |---|---|
+//! | 0x82 data | the property's bytes |
+//! | 0x22 strong reference | UTF-16 name of the child storage |
+//! | 0x32 strong reference vector | UTF-16 collection name; storages `name{key}`, stream `name index`: u32 count, u32 first free key, u32 last free key, count × u32 local key |
+//! | 0x3A strong reference set | as a vector; the index adds u16 key pid, u8 key size, and each entry is u32 local key, u32 reference count, key bytes |
+//! | 0x02 weak reference | u16 referenced-property tag, u16 key pid, u8 key size, key bytes |
+//! | 0x12 / 0x1A weak reference vector / set | UTF-16 collection name; stream `name index`: u32 count, u16 tag, u16 key pid, u8 key size, count × key |
+//! | 0x42 data stream | u8 byte order, UTF-16 name of the stream |
+//!
+//! `referenced properties` (root stream): u8 byte order, u16 path count, u32 pid count, then the
+//! pids of each path (from the root object) terminated by a 0 pid.
+
+use filmcraft_cfb::{CompoundFile, EntryKind, NodeId, Version, Writer};
+
+pub(crate) type Auid = [u8; 16];
+
+pub(crate) const SF_DATA: u16 = 0x82;
+pub(crate) const SF_DATA_STREAM: u16 = 0x42;
+pub(crate) const SF_STRONG: u16 = 0x22;
+pub(crate) const SF_STRONG_VECTOR: u16 = 0x32;
+pub(crate) const SF_STRONG_SET: u16 = 0x3A;
+pub(crate) const SF_WEAK: u16 = 0x02;
+pub(crate) const SF_WEAK_VECTOR: u16 = 0x12;
+pub(crate) const SF_WEAK_SET: u16 = 0x1A;
+
+const BYTE_ORDER_LE: u8 = 0x4C;
+const FORMAT_VERSION: u8 = 0x20;
+
+/// A property value.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum Value {
+ Data(Vec),
+ Strong(Box),
+ StrongVec(Vec),
+ /// Objects keyed by the data property `key_pid` of each.
+ StrongSet(Vec, u16),
+ /// (path of pids from the root to the target collection, key pid, key)
+ Weak(Vec, u16, Vec),
+ WeakVec(Vec, u16, Vec>),
+ Stream(Vec),
+}
+
+/// An AAF object: its class and properties.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct Obj {
+ pub class: Auid,
+ pub props: Vec<(u16, Value)>,
+}
+
+impl Obj {
+ pub fn new(class: Auid) -> Obj {
+ Obj { class, props: Vec::new() }
+ }
+ pub fn set(&mut self, pid: u16, v: Value) -> &mut Obj {
+ match self.props.iter_mut().find(|(p, _)| *p == pid) {
+ Some(slot) => slot.1 = v,
+ None => self.props.push((pid, v)),
+ }
+ self
+ }
+ pub fn with(mut self, pid: u16, v: Value) -> Obj {
+ self.set(pid, v);
+ self
+ }
+ pub fn data_prop(mut self, pid: u16, d: Vec) -> Obj {
+ self.set(pid, Value::Data(d));
+ self
+ }
+ pub fn get(&self, pid: u16) -> Option<&Value> {
+ self.props.iter().find(|(p, _)| *p == pid).map(|(_, v)| v)
+ }
+ pub fn data(&self, pid: u16) -> Option<&[u8]> {
+ match self.get(pid)? {
+ Value::Data(d) => Some(d),
+ _ => None,
+ }
+ }
+ pub fn strong(&self, pid: u16) -> Option<&Obj> {
+ match self.get(pid)? {
+ Value::Strong(o) => Some(o),
+ _ => None,
+ }
+ }
+ /// Elements of a strong vector or set (empty when absent).
+ pub fn objs(&self, pid: u16) -> &[Obj] {
+ match self.get(pid) {
+ Some(Value::StrongVec(v)) | Some(Value::StrongSet(v, _)) => v,
+ _ => &[],
+ }
+ }
+ pub fn weak_key(&self, pid: u16) -> Option<&[u8]> {
+ match self.get(pid)? {
+ Value::Weak(_, _, k) => Some(k),
+ _ => None,
+ }
+ }
+ pub fn stream(&self, pid: u16) -> Option<&[u8]> {
+ match self.get(pid)? {
+ Value::Stream(d) => Some(d),
+ _ => None,
+ }
+ }
+ pub fn u8(&self, pid: u16) -> Option {
+ self.data(pid).and_then(|d| d.first().copied())
+ }
+ pub fn u16(&self, pid: u16) -> Option {
+ self.data(pid).filter(|d| d.len() >= 2).map(|d| u16::from_le_bytes([d[0], d[1]]))
+ }
+ pub fn u32(&self, pid: u16) -> Option {
+ self.data(pid).filter(|d| d.len() >= 4).map(|d| u32::from_le_bytes([d[0], d[1], d[2], d[3]]))
+ }
+ pub fn i64(&self, pid: u16) -> Option {
+ let d = self.data(pid)?;
+ match d.len() {
+ 8.. => Some(i64::from_le_bytes(d[..8].try_into().ok()?)),
+ 4..=7 => Some(i32::from_le_bytes(d[..4].try_into().ok()?) as i64),
+ _ => None,
+ }
+ }
+ pub fn rational(&self, pid: u16) -> Option<(i64, i64)> {
+ self.data(pid).and_then(rational_of)
+ }
+ pub fn string(&self, pid: u16) -> Option {
+ self.data(pid).map(utf16_of)
+ }
+ pub fn auid(&self, pid: u16) -> Option {
+ self.data(pid).and_then(|d| d.get(..16)).and_then(|d| d.try_into().ok())
+ }
+ pub fn mob_id(&self, pid: u16) -> Option<[u8; 32]> {
+ self.data(pid).and_then(|d| d.get(..32)).and_then(|d| d.try_into().ok())
+ }
+}
+
+pub(crate) fn rational_of(d: &[u8]) -> Option<(i64, i64)> {
+ (d.len() >= 8).then(|| (i32::from_le_bytes([d[0], d[1], d[2], d[3]]) as i64, i32::from_le_bytes([d[4], d[5], d[6], d[7]]) as i64))
+}
+
+pub(crate) fn rational(num: i64, den: i64) -> Vec {
+ let mut v = (num.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes().to_vec();
+ v.extend_from_slice(&(den.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes());
+ v
+}
+
+/// UTF-16LE with a terminating NUL.
+pub(crate) fn utf16z(s: &str) -> Vec {
+ let mut v: Vec = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
+ v.extend_from_slice(&[0, 0]);
+ v
+}
+
+pub(crate) fn utf16_of(d: &[u8]) -> String {
+ let u: Vec = d.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|&c| c != 0).collect();
+ String::from_utf16_lossy(&u)
+}
+
+/// Short property names used to name storages and streams (any name is valid: readers follow
+/// the names stored in the property values).
+fn prop_name(pid: u16) -> &'static str {
+ match pid {
+ 0x0001 => "MetaDictionary",
+ 0x0002 => "Header",
+ 0x3B03 => "Content",
+ 0x3B04 => "Dictionary",
+ 0x3B06 => "IdentificationList",
+ 0x1901 => "Mobs",
+ 0x1902 => "EssenceData",
+ 0x2603 => "OperationDefinitions",
+ 0x2604 => "ParameterDefinitions",
+ 0x2605 => "DataDefinitions",
+ 0x2607 => "CodecDefinitions",
+ 0x2608 => "ContainerDefinitions",
+ 0x2609 => "InterpolationDefs",
+ 0x4403 => "Slots",
+ 0x4406 => "UserComments",
+ 0x4803 => "Segment",
+ 0x1001 => "Components",
+ 0x1801 => "OperationGroup",
+ 0x0B02 => "InputSegments",
+ 0x0B03 => "Parameters",
+ 0x4E02 => "PointList",
+ 0x4701 => "EssenceDescription",
+ 0x2F01 => "Locator",
+ 0x2702 => "Data",
+ 0x0204 => "ComponentComments",
+ 0x1E09 => "ParametersDefined",
+ _ => "Property",
+ }
+}
+
+fn storage_name(pid: u16) -> String {
+ let suffix = format!("-{pid:x}");
+ let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect();
+ format!("{name}{suffix}")
+}
+
+/// Errors while reading the object tree.
+fn bad(s: impl Into) -> String {
+ s.into()
+}
+
+// ---------------------------------------------------------------------------------------------
+// Writing
+// ---------------------------------------------------------------------------------------------
+
+struct W {
+ cfb: Writer,
+ paths: Vec>,
+}
+
+/// Serialise `root` (the Root object: MetaDictionary 0x0001, Header 0x0002) into a compound file.
+pub(crate) fn write(root: &Obj, version: Version) -> Result, String> {
+ let mut w = W { cfb: Writer::new(version), paths: Vec::new() };
+ w.obj(Writer::ROOT, root)?;
+ let mut rp = vec![BYTE_ORDER_LE];
+ rp.extend_from_slice(&(w.paths.len() as u16).to_le_bytes());
+ let n: usize = w.paths.iter().map(|p| p.len() + 1).sum();
+ rp.extend_from_slice(&(n as u32).to_le_bytes());
+ for p in &w.paths {
+ for pid in p {
+ rp.extend_from_slice(&pid.to_le_bytes());
+ }
+ rp.extend_from_slice(&0u16.to_le_bytes());
+ }
+ w.cfb.stream(Writer::ROOT, "referenced properties", rp).map_err(|e| e.to_string())?;
+ Ok(w.cfb.finish())
+}
+
+impl W {
+ fn tag(&mut self, path: &[u16]) -> u16 {
+ match self.paths.iter().position(|p| p == path) {
+ Some(i) => i as u16,
+ None => {
+ self.paths.push(path.to_vec());
+ (self.paths.len() - 1) as u16
+ }
+ }
+ }
+
+ fn obj(&mut self, node: NodeId, o: &Obj) -> Result<(), String> {
+ self.cfb.set_clsid(node, o.class);
+ let mut entries: Vec<(u16, u16, Vec)> = Vec::new();
+ for (pid, v) in &o.props {
+ let pid = *pid;
+ let (sf, value) = match v {
+ Value::Data(d) => (SF_DATA, d.clone()),
+ Value::Strong(child) => {
+ let name = storage_name(pid);
+ let s = self.cfb.storage(node, &name, child.class).map_err(|e| e.to_string())?;
+ self.obj(s, child)?;
+ (SF_STRONG, utf16z(&name))
+ }
+ Value::StrongVec(items) | Value::StrongSet(items, _) => {
+ let name = storage_name(pid);
+ let mut index = Vec::new();
+ index.extend_from_slice(&(items.len() as u32).to_le_bytes());
+ index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key
+ index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key
+ let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None };
+ let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0);
+ if let Some(k) = set_key {
+ index.extend_from_slice(&k.to_le_bytes());
+ index.push(key_size as u8);
+ }
+ for (i, item) in items.iter().enumerate() {
+ let s = self.cfb.storage(node, &format!("{name}{{{i:x}}}"), item.class).map_err(|e| e.to_string())?;
+ self.obj(s, item)?;
+ index.extend_from_slice(&(i as u32).to_le_bytes());
+ if let Some(k) = set_key {
+ index.extend_from_slice(&1u32.to_le_bytes());
+ let mut key = item.data(k).unwrap_or(&[]).to_vec();
+ key.resize(key_size, 0);
+ index.extend_from_slice(&key);
+ }
+ }
+ self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?;
+ (if set_key.is_some() { SF_STRONG_SET } else { SF_STRONG_VECTOR }, utf16z(&name))
+ }
+ Value::Weak(path, key_pid, key) => {
+ let mut d = self.tag(path).to_le_bytes().to_vec();
+ d.extend_from_slice(&key_pid.to_le_bytes());
+ d.push(key.len() as u8);
+ d.extend_from_slice(key);
+ (SF_WEAK, d)
+ }
+ Value::WeakVec(path, key_pid, keys) => {
+ let name = storage_name(pid);
+ let tag = self.tag(path);
+ let size = keys.first().map_or(0, Vec::len);
+ let mut index = (keys.len() as u32).to_le_bytes().to_vec();
+ index.extend_from_slice(&tag.to_le_bytes());
+ index.extend_from_slice(&key_pid.to_le_bytes());
+ index.push(size as u8);
+ for k in keys {
+ let mut k = k.clone();
+ k.resize(size, 0);
+ index.extend_from_slice(&k);
+ }
+ self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?;
+ (SF_WEAK_VECTOR, utf16z(&name))
+ }
+ Value::Stream(data) => {
+ let name = storage_name(pid);
+ self.cfb.stream(node, &name, data.clone()).map_err(|e| e.to_string())?;
+ let mut d = vec![BYTE_ORDER_LE];
+ d.extend_from_slice(&utf16z(&name));
+ (SF_DATA_STREAM, d)
+ }
+ };
+ if value.len() > u16::MAX as usize {
+ return Err(format!("property {pid:#06x} is {} bytes (the limit is 65535)", value.len()));
+ }
+ entries.push((pid, sf, value));
+ }
+ let mut props = vec![BYTE_ORDER_LE, FORMAT_VERSION];
+ props.extend_from_slice(&(entries.len() as u16).to_le_bytes());
+ for (pid, sf, v) in &entries {
+ props.extend_from_slice(&pid.to_le_bytes());
+ props.extend_from_slice(&sf.to_le_bytes());
+ props.extend_from_slice(&(v.len() as u16).to_le_bytes());
+ }
+ for (_, _, v) in &entries {
+ props.extend_from_slice(v);
+ }
+ self.cfb.stream(node, "properties", props).map_err(|e| e.to_string())?;
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Reading
+// ---------------------------------------------------------------------------------------------
+
+struct R<'a> {
+ cf: CompoundFile<'a>,
+ paths: Vec>,
+ objects: usize,
+}
+
+const MAX_DEPTH: usize = 200;
+const MAX_OBJECTS: usize = 2_000_000;
+
+/// Parse the object tree of an AAF compound file (the root object).
+pub(crate) fn read(bytes: &[u8]) -> Result {
+ let cf = CompoundFile::open(bytes).map_err(|e| e.to_string())?;
+ let mut paths = Vec::new();
+ if let Ok(rp) = cf.read_path("referenced properties")
+ && rp.len() >= 7
+ {
+ let count = u16::from_le_bytes([rp[1], rp[2]]) as usize;
+ let mut cur = Vec::new();
+ for c in rp[7..].chunks_exact(2) {
+ let pid = u16::from_le_bytes([c[0], c[1]]);
+ if pid == 0 {
+ paths.push(std::mem::take(&mut cur));
+ if paths.len() == count {
+ break;
+ }
+ } else {
+ cur.push(pid);
+ }
+ }
+ }
+ let mut r = R { cf, paths, objects: 0 };
+ r.obj(0, 0)
+}
+
+fn rd_u16(b: &[u8], at: usize) -> Option {
+ b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]]))
+}
+fn rd_u32(b: &[u8], at: usize) -> Option {
+ b.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
+}
+
+impl R<'_> {
+ fn child(&self, parent: usize, name: &str) -> Result {
+ self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\"")))
+ }
+
+ fn stream(&self, parent: usize, name: &str) -> Result, String> {
+ let id = self.child(parent, name)?;
+ self.cf.read(id).map_err(|e| e.to_string())
+ }
+
+ fn obj(&mut self, entry: usize, depth: usize) -> Result {
+ if depth > MAX_DEPTH {
+ return Err(bad("objects nested too deeply"));
+ }
+ self.objects += 1;
+ if self.objects > MAX_OBJECTS {
+ return Err(bad("too many objects"));
+ }
+ let e = self.cf.entry(entry).ok_or_else(|| bad("bad directory entry"))?;
+ if !matches!(e.kind, EntryKind::Root | EntryKind::Storage) {
+ return Err(bad(format!("\"{}\" is not a storage", e.name)));
+ }
+ let mut o = Obj::new(e.clsid);
+ let props = self.stream(entry, "properties")?;
+ if props.len() < 4 {
+ return Err(bad("short properties stream"));
+ }
+ if props[0] != BYTE_ORDER_LE {
+ return Err(bad("big-endian AAF files are not supported"));
+ }
+ let n = rd_u16(&props, 2).unwrap_or(0) as usize;
+ let mut at = 4 + n * 6;
+ if at > props.len() {
+ return Err(bad("truncated property index"));
+ }
+ for i in 0..n {
+ let (Some(pid), Some(sf), Some(len)) = (rd_u16(&props, 4 + i * 6), rd_u16(&props, 6 + i * 6), rd_u16(&props, 8 + i * 6)) else {
+ return Err(bad("truncated property index"));
+ };
+ let v = props.get(at..at + len as usize).ok_or_else(|| bad("truncated property value"))?;
+ at += len as usize;
+ let value = match sf {
+ SF_STRONG => {
+ let c = self.child(entry, &utf16_of(v))?;
+ Value::Strong(Box::new(self.obj(c, depth + 1)?))
+ }
+ SF_STRONG_VECTOR | SF_STRONG_SET => {
+ let name = utf16_of(v);
+ let idx = self.stream(entry, &format!("{name} index"))?;
+ let count = rd_u32(&idx, 0).ok_or_else(|| bad("short collection index"))? as usize;
+ let mut items = Vec::new();
+ if sf == SF_STRONG_VECTOR {
+ for k in 0..count {
+ let key = rd_u32(&idx, 12 + k * 4).ok_or_else(|| bad("short vector index"))?;
+ let c = self.child(entry, &format!("{name}{{{key:x}}}"))?;
+ items.push(self.obj(c, depth + 1)?);
+ }
+ Value::StrongVec(items)
+ } else {
+ let key_pid = rd_u16(&idx, 12).ok_or_else(|| bad("short set index"))?;
+ let key_size = *idx.get(14).ok_or_else(|| bad("short set index"))? as usize;
+ let stride = 8 + key_size;
+ for k in 0..count {
+ let key = rd_u32(&idx, 15 + k * stride).ok_or_else(|| bad("short set index"))?;
+ let c = self.child(entry, &format!("{name}{{{key:x}}}"))?;
+ items.push(self.obj(c, depth + 1)?);
+ }
+ Value::StrongSet(items, key_pid)
+ }
+ }
+ SF_WEAK => {
+ let (Some(tag), Some(key_pid), Some(&size)) = (rd_u16(v, 0), rd_u16(v, 2), v.get(4)) else {
+ return Err(bad("short weak reference"));
+ };
+ let key = v.get(5..5 + size as usize).ok_or_else(|| bad("short weak reference"))?.to_vec();
+ Value::Weak(self.paths.get(tag as usize).cloned().unwrap_or_default(), key_pid, key)
+ }
+ SF_WEAK_VECTOR | SF_WEAK_SET => {
+ let name = utf16_of(v);
+ let idx = self.stream(entry, &format!("{name} index"))?;
+ let (Some(count), Some(tag), Some(key_pid), Some(&size)) = (rd_u32(&idx, 0), rd_u16(&idx, 4), rd_u16(&idx, 6), idx.get(8)) else {
+ return Err(bad("short weak collection index"));
+ };
+ let size = size as usize;
+ let mut keys = Vec::new();
+ for k in 0..count as usize {
+ keys.push(idx.get(9 + k * size..9 + (k + 1) * size).ok_or_else(|| bad("short weak collection index"))?.to_vec());
+ }
+ Value::WeakVec(self.paths.get(tag as usize).cloned().unwrap_or_default(), key_pid, keys)
+ }
+ SF_DATA_STREAM => {
+ let name = utf16_of(v.get(1..).unwrap_or(&[]));
+ Value::Stream(self.stream(entry, &name)?)
+ }
+ _ => Value::Data(v.to_vec()),
+ };
+ o.props.push((pid, value));
+ }
+ Ok(o)
+ }
+}
diff --git a/crates/interchange/src/aaf/tests.rs b/crates/interchange/src/aaf/tests.rs
new file mode 100644
index 00000000..7f906b7b
--- /dev/null
+++ b/crates/interchange/src/aaf/tests.rs
@@ -0,0 +1,266 @@
+//! Structural checks of written AAF files against the required properties and rules of the AAF
+//! Object Specification v1.1 and the Edit Protocol, read back through our compound file reader.
+
+use std::collections::HashSet;
+
+use filmcraft_project::{Project, SequenceSettings, TrackKind, TransitionAlign, TransitionId, find_effect};
+use filmcraft_time::{FrameRate, TimeRange};
+
+use super::ids::{self, cls, ddef, def, path, pid};
+use super::store::{self, Obj, Value};
+use super::*;
+
+fn project() -> (Project, ItemId) {
+ let mut p = Project::new("t");
+ let info = filmcraft_media::MediaInfo {
+ name: "a.mov".into(),
+ kind: filmcraft_media::MediaKind::Movie,
+ duration: FrameRate::FPS_24.tick_of(2400),
+ video: Some(filmcraft_media::VideoStreamInfo {
+ width: 1280,
+ height: 720,
+ frame_rate: FrameRate::FPS_24,
+ par: (1, 1),
+ codec: String::new(),
+ pixel_format: String::new(),
+ color: Default::default(),
+ has_alpha: false,
+ bitrate: None,
+ }),
+ audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(24) }),
+ container: String::new(),
+ start_timecode: Some(86_400),
+ file_size: None,
+ };
+ let a = p.add_item(
+ "a.mov",
+ filmcraft_project::Label::Iris,
+ filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
+ media: filmcraft_project::MediaRef::File { path: "/m/a.mov".into() },
+ info,
+ interpret: Default::default(),
+ mark_in: None,
+ mark_out: None,
+ markers: vec![],
+ offline: false,
+ proxy: None,
+ identity: None,
+ }),
+ None,
+ );
+ let r = FrameRate::FPS_24;
+ let s = p.new_sequence("S", SequenceSettings { frame_rate: r, ..Default::default() }, 1, 1, None);
+ let mut ids = Vec::new();
+ for (kind, start, dur, src) in
+ [(TrackKind::Video, 0, 48, 10), (TrackKind::Video, 48, 48, 200), (TrackKind::Audio, 0, 48, 10), (TrackKind::Audio, 48, 48, 200)]
+ {
+ let ti = p.make_track_item(a, kind, r.tick_of(start), TimeRange::new(r.tick_of(src), r.tick_of(dur)), r).unwrap();
+ ids.push(ti.id);
+ let q = p.sequence_mut(s).unwrap();
+ q.tracks_mut(kind)[0].items.push(ti);
+ }
+ for (kind, i, eff) in [(TrackKind::Video, 0, "cross_dissolve"), (TrackKind::Audio, 2, "constant_power")] {
+ let id = TransitionId(p.alloc_id());
+ let q = p.sequence_mut(s).unwrap();
+ q.tracks_mut(kind)[0].transitions.push(filmcraft_project::Transition {
+ id,
+ effect: find_effect(eff).unwrap().instance(),
+ start: r.tick_of(42),
+ duration: r.tick_of(12),
+ from: Some(ids[i]),
+ to: Some(ids[i + 1]),
+ align: TransitionAlign::CenterAtCut,
+ reverse: false,
+ });
+ }
+ let q = p.sequence_mut(s).unwrap();
+ q.audio_tracks[0].items[1].gain_db = -3.0;
+ (p, s)
+}
+
+fn walk<'a>(o: &'a Obj, out: &mut Vec<&'a Obj>) {
+ out.push(o);
+ for (_, v) in &o.props {
+ match v {
+ Value::Strong(c) => walk(c, out),
+ Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| walk(c, out)),
+ _ => {}
+ }
+ }
+}
+
+fn keys(dict: &Obj, p: u16) -> HashSet> {
+ dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect()
+}
+
+#[test]
+fn written_files_have_the_required_structure() {
+ let (p, s) = project();
+ for small in [false, true] {
+ let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
+ // compound file level
+ let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap();
+ assert_eq!(cf.version, if small { 3 } else { 4 });
+ assert_eq!(cf.root().clsid, ids::ROOT);
+ assert!(cf.read_path("referenced properties").is_ok());
+ let h = cf.find("Header-2").unwrap();
+ assert_eq!(cf.entries[h].clsid, cls::HEADER);
+ assert!(cf.read_path("Header-2/properties").unwrap().starts_with(&[0x4C, 0x20]));
+
+ // object level
+ let root = store::read(&bytes).unwrap();
+ assert!(root.strong(pid::ROOT_META_DICTIONARY).is_some());
+ let header = root.strong(pid::ROOT_HEADER).unwrap();
+ for req in [pid::BYTE_ORDER, pid::LAST_MODIFIED, pid::CONTENT, pid::DICTIONARY, pid::VERSION, pid::IDENTIFICATION_LIST, pid::OBJECT_MODEL_VERSION] {
+ assert!(header.get(req).is_some(), "Header property {req:#06x}");
+ }
+ assert_eq!(header.auid(pid::OPERATIONAL_PATTERN), Some(def::OP_EDIT_PROTOCOL));
+ let ident = &header.objs(pid::IDENTIFICATION_LIST)[0];
+ for req in [pid::COMPANY_NAME, pid::PRODUCT_NAME, pid::PRODUCT_VERSION_STRING, pid::PRODUCT_ID, pid::DATE, pid::GENERATION_AUID] {
+ assert!(ident.get(req).is_some(), "Identification property {req:#06x}");
+ }
+ let dict = header.strong(pid::DICTIONARY).unwrap();
+ let ddefs = keys(dict, pid::DATA_DEFINITIONS);
+ let opdefs = keys(dict, pid::OPERATION_DEFINITIONS);
+ let idefs = keys(dict, pid::INTERPOLATION_DEFINITIONS);
+ let cdefs = keys(dict, pid::CONTAINER_DEFINITIONS);
+ let pdefs = keys(dict, pid::PARAMETER_DEFINITIONS);
+ let content = header.strong(pid::CONTENT).unwrap();
+ let mobs = content.objs(pid::MOBS);
+ let mob_ids: Vec<[u8; 32]> = mobs.iter().map(|m| m.mob_id(pid::MOB_ID).unwrap()).collect();
+ assert_eq!(mob_ids.iter().collect::>().len(), mob_ids.len(), "unique mob ids");
+ assert!(matches!(content.get(pid::MOBS), Some(Value::StrongSet(_, k)) if *k == pid::MOB_ID));
+ let comps: Vec<&Obj> = mobs.iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect();
+ assert_eq!(comps.len(), 1);
+ assert_eq!(comps[0].auid(pid::USAGE_CODE), Some(def::USAGE_TOP_LEVEL));
+ assert_eq!(mobs.iter().filter(|m| m.class == cls::MASTER_MOB).count(), 1);
+ assert_eq!(mobs.iter().filter(|m| m.class == cls::SOURCE_MOB).count(), 3, "picture file, sound file, tape");
+ for m in mobs {
+ for req in [pid::MOB_ID, pid::MOB_NAME, pid::SLOTS, pid::MOB_LAST_MODIFIED, pid::MOB_CREATION_TIME] {
+ assert!(m.get(req).is_some(), "Mob property {req:#06x}");
+ }
+ if m.class == cls::SOURCE_MOB {
+ let d = m.strong(pid::ESSENCE_DESCRIPTION).expect("SourceMob::EssenceDescription");
+ if d.class != cls::TAPE_DESCRIPTOR {
+ for req in [pid::SAMPLE_RATE, pid::FILE_LENGTH, pid::CONTAINER_FORMAT] {
+ assert!(d.get(req).is_some(), "FileDescriptor property {req:#06x}");
+ }
+ assert!(cdefs.contains(d.weak_key(pid::CONTAINER_FORMAT).unwrap()));
+ let url = d.objs(pid::LOCATOR)[0].string(pid::URL_STRING).unwrap();
+ assert_eq!(url, "file:///m/a.mov");
+ }
+ if d.class == cls::PCM_DESCRIPTOR {
+ assert_eq!(d.u32(pid::CHANNELS), Some(2));
+ assert_eq!(d.u32(pid::QUANTIZATION_BITS), Some(24));
+ assert_eq!(d.u16(pid::BLOCK_ALIGN), Some(6));
+ }
+ }
+ for slot in m.objs(pid::SLOTS) {
+ assert!(slot.get(pid::SLOT_ID).is_some() && slot.get(pid::SEGMENT).is_some());
+ if slot.class == cls::TIMELINE_MOB_SLOT {
+ assert!(slot.rational(pid::EDIT_RATE).is_some_and(|r| r.0 > 0 && r.1 > 0));
+ assert!(slot.get(pid::ORIGIN).is_some());
+ }
+ }
+ let mut all = Vec::new();
+ walk(m, &mut all);
+ for o in all {
+ let is_component = [cls::FILLER, cls::SOURCE_CLIP, cls::SEQUENCE, cls::TRANSITION, cls::OPERATION_GROUP, cls::TIMECODE, cls::COMMENT_MARKER]
+ .contains(&o.class);
+ if is_component {
+ let dd = o.weak_key(pid::DATA_DEFINITION).expect("Component::DataDefinition");
+ assert!(ddefs.contains(dd), "data definition in the dictionary");
+ let event_seq = o.class == cls::SEQUENCE && dd == ddef::DESCRIPTIVE_METADATA;
+ assert!(event_seq || o.get(pid::LENGTH).is_some(), "Component::Length");
+ }
+ if o.class == cls::SOURCE_CLIP {
+ let id = o.mob_id(pid::SOURCE_ID).unwrap();
+ let slot = o.u32(pid::SOURCE_MOB_SLOT_ID).unwrap();
+ assert!(o.get(pid::START_TIME).is_some());
+ if id != [0; 32] {
+ let t = mobs.iter().find(|m| m.mob_id(pid::MOB_ID) == Some(id)).expect("source clip target in the file");
+ assert!(t.objs(pid::SLOTS).iter().any(|s| s.u32(pid::SLOT_ID) == Some(slot)), "target slot exists");
+ }
+ }
+ if o.class == cls::OPERATION_GROUP {
+ assert!(opdefs.contains(o.weak_key(pid::OPERATION).unwrap()));
+ for prm in o.objs(pid::PARAMETERS) {
+ assert!(pdefs.contains(prm.auid(pid::PARAMETER_DEFINITION).unwrap().as_slice()));
+ if prm.class == cls::VARYING_VALUE {
+ assert!(idefs.contains(prm.weak_key(pid::INTERPOLATION).unwrap()));
+ assert!(!prm.objs(pid::POINT_LIST).is_empty());
+ }
+ }
+ }
+ if o.class == cls::SEQUENCE && o.get(pid::LENGTH).is_some() {
+ let cs = o.objs(pid::COMPONENTS);
+ let mut sum = 0i64;
+ for (i, c) in cs.iter().enumerate() {
+ let l = c.i64(pid::LENGTH).unwrap();
+ if c.class == cls::TRANSITION {
+ // Edit Protocol: a transition sits between two segments at least as long
+ assert!(i > 0 && i + 1 < cs.len(), "transition at a sequence end");
+ assert!(cs[i - 1].class != cls::TRANSITION && cs[i + 1].class != cls::TRANSITION);
+ assert!(cs[i - 1].i64(pid::LENGTH).unwrap() >= l && cs[i + 1].i64(pid::LENGTH).unwrap() >= l);
+ let cut = c.i64(pid::CUT_POINT).unwrap();
+ assert!((0..=l).contains(&cut));
+ assert!(c.strong(pid::OPERATION_GROUP).is_some());
+ sum -= l;
+ } else {
+ sum += l;
+ }
+ }
+ assert_eq!(sum, o.i64(pid::LENGTH).unwrap(), "sequence length = segments − transitions");
+ }
+ }
+ }
+ // the composition: timecode + V1 + A1 slots, the video dissolve and the gain
+ let comp = comps[0];
+ let slots = comp.objs(pid::SLOTS);
+ let tc = slots[0].strong(pid::SEGMENT).unwrap();
+ assert_eq!(tc.class, cls::TIMECODE);
+ assert_eq!(tc.u16(pid::TC_FPS), Some(24));
+ let v1 = slots[1].strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS);
+ assert_eq!(v1.iter().map(|c| c.class).collect::>(), vec![cls::SOURCE_CLIP, cls::TRANSITION, cls::SOURCE_CLIP]);
+ assert_eq!(v1[0].i64(pid::LENGTH), Some(54), "outgoing clip extends to the transition end");
+ assert_eq!(v1[2].i64(pid::LENGTH), Some(54), "incoming clip starts at the transition start");
+ assert_eq!(v1[2].i64(pid::START_TIME), Some(194));
+ assert_eq!(v1[1].i64(pid::CUT_POINT), Some(6));
+ assert_eq!(v1[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::VIDEO_DISSOLVE[..]));
+ let a1 = slots[2].strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS);
+ assert_eq!(slots[2].rational(pid::EDIT_RATE), Some((48_000, 1)));
+ assert_eq!(a1[2].class, cls::OPERATION_GROUP);
+ assert_eq!(a1[2].weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_GAIN[..]));
+ let amp = a1[2].objs(pid::PARAMETERS)[0].data(pid::CONSTANT_VALUE).unwrap();
+ let (n, d) = store::rational_of(&[17..]).unwrap();
+ assert!((n as f64 / d as f64 - 10f64.powf(-3.0 / 20.0)).abs() < 1e-4);
+ assert_eq!(a1[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_DISSOLVE[..]));
+ // weak reference paths are registered
+ let rp = cf.read_path("referenced properties").unwrap();
+ let pids: Vec = rp[7..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
+ assert!(pids.windows(3).any(|w| w == path::DATA_DEFS));
+ }
+}
+
+#[test]
+fn store_round_trips_every_stored_form() {
+ let leaf = |n: u8| Obj::new([n; 16]).data_prop(0x1B01, vec![n; 16]);
+ let root = Obj::new(ids::ROOT)
+ .data_prop(0x0003, vec![1, 2, 3])
+ .with(0x0004, Value::Strong(Box::new(leaf(1).with(0x0005, Value::StrongVec(vec![leaf(2), leaf(3)])))))
+ .with(
+ 0x0002,
+ Value::Strong(Box::new(
+ Obj::new([9; 16]).with(0x3B04, Value::Strong(Box::new(Obj::new([8; 16]).with(0x2605, Value::StrongSet(vec![leaf(4), leaf(5)], 0x1B01))))),
+ )),
+ )
+ .with(0x0006, Value::Weak(path::DATA_DEFS.to_vec(), 0x1B01, vec![5; 16]))
+ .with(0x0007, Value::WeakVec(path::DATA_DEFS.to_vec(), 0x1B01, vec![vec![4; 16], vec![5; 16]]))
+ .with(0x0008, Value::Stream((0..10_000u32).map(|i| i as u8).collect()));
+ for v in [filmcraft_cfb::Version::V3, filmcraft_cfb::Version::V4] {
+ let bytes = store::write(&root, v).unwrap();
+ assert_eq!(store::read(&bytes).unwrap(), root);
+ }
+ // a data value longer than a property can hold is an error, not a panic
+ assert!(store::write(&Obj::new(ids::ROOT).data_prop(1, vec![0; 70_000]), filmcraft_cfb::Version::V4).is_err());
+}
diff --git a/crates/interchange/src/aaf/write.rs b/crates/interchange/src/aaf/write.rs
new file mode 100644
index 00000000..97067800
--- /dev/null
+++ b/crates/interchange/src/aaf/write.rs
@@ -0,0 +1,619 @@
+//! Composition model → AAF objects.
+
+use std::collections::HashMap;
+
+use filmcraft_time::Tick;
+
+use super::ids::{META_DICTIONARY, ROOT, cls, ddef, def, path, pid};
+use super::store::{self, Auid, Obj, Value, rational, utf16z};
+use crate::comp::{CItem, CKind, CMarker, Document, Gain, Source, ticks_to_units};
+use crate::{Error, Report, Result};
+
+/// UMID-style mob id (SMPTE 330 basic UMID): label, length 0x13, instance 0, material number.
+pub(crate) fn mob_id(seed: u64, n: u64) -> [u8; 32] {
+ let mut id = [0u8; 32];
+ id[..12].copy_from_slice(&[0x06, 0x0A, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x05, 0x01, 0x01, 0x0F, 0x20]);
+ id[12] = 0x13;
+ let a = mix(seed ^ n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
+ let b = mix(a ^ 0xD6E8_FEB8_6659_FD93);
+ id[16..24].copy_from_slice(&a.to_le_bytes());
+ id[24..32].copy_from_slice(&b.to_le_bytes());
+ // a "random" material number per RFC 4122 v4 layout keeps it a valid UUID
+ id[22] = (id[22] & 0x0F) | 0x40;
+ id[24] = (id[24] & 0x3F) | 0x80;
+ id
+}
+
+fn mix(mut z: u64) -> u64 {
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+}
+
+pub(crate) fn fnv(s: &[u8], h: u64) -> u64 {
+ s.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3))
+}
+
+fn timestamp() -> Vec {
+ // 1970-01-01 00:00:00.00: FilmCraft writes reproducible files
+ let mut v = 1970i16.to_le_bytes().to_vec();
+ v.extend_from_slice(&[1, 1, 0, 0, 0, 0]);
+ v
+}
+
+fn weak(path: [u16; 3], key: Auid) -> Value {
+ Value::Weak(path.to_vec(), pid::IDENTIFICATION, key.to_vec())
+}
+
+fn data_def(kind: CKind) -> Auid {
+ match kind {
+ CKind::Picture => ddef::PICTURE,
+ CKind::Sound => ddef::SOUND,
+ }
+}
+
+/// An indirect value: byte order, type id, value.
+pub(crate) fn indirect(ty: Auid, v: &[u8]) -> Vec {
+ let mut d = vec![0x4C];
+ d.extend_from_slice(&ty);
+ d.extend_from_slice(v);
+ d
+}
+
+fn tagged(name: &str, value: &str) -> Obj {
+ Obj::new(cls::TAGGED_VALUE).data_prop(pid::TAG_NAME, utf16z(name)).data_prop(pid::TAG_VALUE, indirect(def::TYPE_STRING, &utf16z(value)))
+}
+
+fn gain_rational(g: f64) -> Vec {
+ rational((g.max(0.0) * 65536.0).round().min(i32::MAX as f64) as i64, 65536)
+}
+
+fn definition(class: Auid, id: Auid, name: &str) -> Obj {
+ Obj::new(class).data_prop(pid::IDENTIFICATION, id.to_vec()).data_prop(pid::NAME, utf16z(name)).data_prop(pid::DESCRIPTION, utf16z(""))
+}
+
+struct W<'a> {
+ report: &'a mut Report,
+ inexact: bool,
+}
+
+impl W<'_> {
+ /// Edit units at `rate` of tick `t`.
+ fn u(&mut self, t: Tick, rate: (i64, i64)) -> i64 {
+ let (n, exact) = ticks_to_units(t, rate.0, rate.1);
+ if !exact {
+ self.inexact = true;
+ }
+ n
+ }
+}
+
+fn source_rate(s: &Source) -> (i64, i64) {
+ match s.kind {
+ CKind::Picture => (s.frame_rate.num, s.frame_rate.den),
+ CKind::Sound => (s.sample_rate.max(1) as i64, 1),
+ }
+}
+
+/// Serialise a composition document.
+pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mut Report) -> Result> {
+ let comp = doc.compositions.first().ok_or(Error::Empty)?;
+ let mut w = W { report, inexact: false };
+ let mut seed = fnv(comp.name.as_bytes(), 0xCBF2_9CE4_8422_2325);
+ for s in &doc.sources {
+ seed = fnv(s.path.as_deref().unwrap_or("").as_bytes(), fnv(s.key.as_bytes(), seed));
+ }
+ seed = fnv(&(comp.tracks.len() as u64).to_le_bytes(), seed);
+ let mut next = 0u64;
+ let mut new_id = || {
+ next += 1;
+ mob_id(seed, next)
+ };
+ let mut mobs: Vec = Vec::new();
+ let mut essence: Vec = Vec::new();
+ let mut used_ops: Vec = Vec::new();
+ let mut uses_varying = false;
+
+ // ---- media: master mobs (one per source key), file mobs (one per file and kind), tape mobs
+ let mut groups: Vec<(String, Vec)> = Vec::new();
+ for (i, s) in doc.sources.iter().enumerate() {
+ match groups.iter_mut().find(|(k, _)| *k == s.key) {
+ Some((_, v)) => v.push(i),
+ None => groups.push((s.key.clone(), vec![i])),
+ }
+ }
+ // source index → (master mob id, master slot id)
+ let mut master_ref: HashMap = HashMap::new();
+ for (_, members) in &groups {
+ let master_id = new_id();
+ let first = &doc.sources[members[0]];
+ // tape mob: timecode + one slot per essence kind
+ let tc = members.iter().find_map(|&i| doc.sources[i].start_tc.map(|t| (t, doc.sources[i].tc_rate)));
+ let tape_id = new_id();
+ let mut tape_slots = Vec::new();
+ if let Some((start, rate)) = tc {
+ let tcr = (rate.num, rate.den);
+ let len = members.iter().map(|&i| doc.sources[i].length).max().unwrap_or(Tick::ZERO);
+ let l = w.u(len, tcr);
+ tape_slots.push(timeline_slot(
+ 1,
+ "TC1",
+ 0,
+ tcr,
+ Obj::new(cls::TIMECODE)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::TIMECODE))
+ .data_prop(pid::LENGTH, l.to_le_bytes().to_vec())
+ .data_prop(pid::TC_START, start.to_le_bytes().to_vec())
+ .data_prop(pid::TC_FPS, (rate.timecode_base().clamp(1, u16::MAX as i64) as u16).to_le_bytes().to_vec())
+ .data_prop(pid::TC_DROP, vec![0]),
+ ));
+ }
+ // file mobs keyed by (file, kind)
+ let mut files: Vec<((Option, usize, CKind), [u8; 32], Vec, usize)> = Vec::new();
+ let mut master_slots = Vec::new();
+ for (k, &si) in members.iter().enumerate() {
+ let s = &doc.sources[si];
+ let rate = source_rate(s);
+ let embedded_key = if s.embedded.is_some() { si } else { usize::MAX };
+ let fkey = (s.path.clone(), embedded_key, s.kind);
+ let file_slot = s.channel.map_or(1, |c| c + 1);
+ let len_units = w.u(s.length, rate);
+ let fi = match files.iter().position(|f| f.0 == fkey) {
+ Some(i) => i,
+ None => {
+ files.push((fkey, new_id(), Vec::new(), si));
+ files.len() - 1
+ }
+ };
+ let file_id = files[fi].1;
+ // the file slot → tape mob
+ let tape_slot_id = 2 + k as u32;
+ let tc_units = 0i64;
+ let clip = source_clip(s.kind, len_units, if tc.is_some() { tape_id } else { [0; 32] }, if tc.is_some() { tape_slot_id } else { 0 }, tc_units);
+ if !files[fi].2.iter().any(|o| o.u32(pid::SLOT_ID) == Some(file_slot)) {
+ files[fi].2.push(timeline_slot(file_slot, &slot_name(s.kind, file_slot), file_slot, rate, clip));
+ }
+ if tc.is_some() {
+ tape_slots.push(timeline_slot(
+ tape_slot_id,
+ &slot_name(s.kind, k as u32 + 1),
+ k as u32 + 1,
+ rate,
+ source_clip(s.kind, len_units, [0; 32], 0, 0),
+ ));
+ }
+ let master_slot = k as u32 + 1;
+ master_slots.push(timeline_slot(
+ master_slot,
+ &slot_name(s.kind, master_slot),
+ master_slot,
+ rate,
+ source_clip(s.kind, len_units, file_id, file_slot, 0),
+ ));
+ master_ref.insert(si, (master_id, master_slot));
+ }
+ // markers on the master mob (at the picture rate, else the first source's)
+ let marker_rate = members.iter().map(|&i| &doc.sources[i]).find(|s| s.kind == CKind::Picture).map(source_rate).unwrap_or_else(|| source_rate(first));
+ let markers: Vec<&CMarker> = members.iter().flat_map(|&i| doc.sources[i].markers.iter()).collect();
+ if !markers.is_empty() {
+ let mut uniq: Vec = markers.into_iter().cloned().collect();
+ uniq.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
+ uniq.dedup();
+ master_slots.push(event_slot(&mut w, members.len() as u32 + 1, marker_rate, &uniq));
+ }
+ mobs.push(
+ Obj::new(cls::MASTER_MOB)
+ .data_prop(pid::MOB_ID, master_id.to_vec())
+ .data_prop(pid::MOB_NAME, utf16z(&first.name))
+ .data_prop(pid::MOB_LAST_MODIFIED, timestamp())
+ .data_prop(pid::MOB_CREATION_TIME, timestamp())
+ .with(pid::SLOTS, Value::StrongVec(master_slots)),
+ );
+ for (_, file_id, slots, si) in files {
+ let s = &doc.sources[si];
+ let desc = descriptor(&mut w, s);
+ mobs.push(
+ Obj::new(cls::SOURCE_MOB)
+ .data_prop(pid::MOB_ID, file_id.to_vec())
+ .data_prop(pid::MOB_NAME, utf16z(&s.name))
+ .data_prop(pid::MOB_LAST_MODIFIED, timestamp())
+ .data_prop(pid::MOB_CREATION_TIME, timestamp())
+ .with(pid::SLOTS, Value::StrongVec(slots))
+ .with(pid::ESSENCE_DESCRIPTION, Value::Strong(Box::new(desc))),
+ );
+ if let Some(data) = &s.embedded {
+ essence
+ .push(Obj::new(cls::ESSENCE_DATA).data_prop(pid::ESSENCE_MOB_ID, file_id.to_vec()).with(pid::ESSENCE_STREAM, Value::Stream(data.clone())));
+ }
+ }
+ if tc.is_some() {
+ let reel = first.path.as_deref().map(crate::common::file_stem).unwrap_or(&first.name).to_string();
+ mobs.push(
+ Obj::new(cls::SOURCE_MOB)
+ .data_prop(pid::MOB_ID, tape_id.to_vec())
+ .data_prop(pid::MOB_NAME, utf16z(&reel))
+ .data_prop(pid::MOB_LAST_MODIFIED, timestamp())
+ .data_prop(pid::MOB_CREATION_TIME, timestamp())
+ .with(pid::SLOTS, Value::StrongVec(tape_slots))
+ .with(pid::ESSENCE_DESCRIPTION, Value::Strong(Box::new(Obj::new(cls::TAPE_DESCRIPTOR)))),
+ );
+ }
+ }
+
+ // ---- the composition
+ let crate_rate = (comp.rate.num, comp.rate.den);
+ let mut slots = Vec::new();
+ let total = comp
+ .tracks
+ .iter()
+ .map(|t| {
+ let mut c = Tick::ZERO;
+ for i in &t.items {
+ match i {
+ CItem::Transition(x) => c -= x.len,
+ other => c += other.len(),
+ }
+ }
+ c
+ })
+ .max()
+ .unwrap_or(Tick::ZERO);
+ let total_units = w.u(total, crate_rate);
+ slots.push(timeline_slot(
+ 1,
+ "TC1",
+ 0,
+ crate_rate,
+ Obj::new(cls::TIMECODE)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::TIMECODE))
+ .data_prop(pid::LENGTH, total_units.to_le_bytes().to_vec())
+ .data_prop(pid::TC_START, comp.start_tc.to_le_bytes().to_vec())
+ .data_prop(pid::TC_FPS, (comp.rate.timecode_base().clamp(1, u16::MAX as i64) as u16).to_le_bytes().to_vec())
+ .data_prop(pid::TC_DROP, vec![comp.drop as u8]),
+ ));
+ let mut slot_id = 2u32;
+ for t in &comp.tracks {
+ let rate = match t.kind {
+ CKind::Picture => crate_rate,
+ CKind::Sound => (comp.sample_rate.max(1) as i64, 1),
+ };
+ let dd = data_def(t.kind);
+ let mut comps = Vec::new();
+ let mut cursor = Tick::ZERO;
+ for it in &t.items {
+ match it {
+ CItem::Filler(l) => {
+ let n = w.u(cursor + *l, rate) - w.u(cursor, rate);
+ cursor += *l;
+ comps.push(Obj::new(cls::FILLER).with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd)).data_prop(pid::LENGTH, n.to_le_bytes().to_vec()));
+ }
+ CItem::Clip(c) => {
+ let n = w.u(cursor + c.len, rate) - w.u(cursor, rate);
+ let s = &doc.sources[c.source];
+ let (mid, mslot) = master_ref.get(&c.source).copied().unwrap_or(([0; 32], 0));
+ let start = w.u(c.start, rate);
+ let mut sc = source_clip(t.kind, n, mid, mslot, start);
+ if !c.name.is_empty() && c.name != s.name {
+ sc.set(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(vec![tagged("Clip Name", &c.name)]));
+ }
+ let seg = match (&c.gain, t.kind) {
+ (Some(g), CKind::Sound) => {
+ if !used_ops.contains(&def::MONO_AUDIO_GAIN) {
+ used_ops.push(def::MONO_AUDIO_GAIN);
+ }
+ let param = match g {
+ Gain::Constant(a) => Obj::new(cls::CONSTANT_VALUE)
+ .data_prop(pid::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE.to_vec())
+ .data_prop(pid::CONSTANT_VALUE, indirect(def::TYPE_RATIONAL, &gain_rational(*a))),
+ Gain::Varying { linear, points } => {
+ uses_varying = true;
+ let pts = points
+ .iter()
+ .map(|(off, a)| {
+ let o = w.u(cursor + *off, rate) - w.u(cursor, rate);
+ Obj::new(cls::CONTROL_POINT)
+ .data_prop(pid::CP_TIME, rational(o, n.max(1)))
+ .data_prop(pid::CP_VALUE, indirect(def::TYPE_RATIONAL, &gain_rational(*a)))
+ .data_prop(pid::CP_EDIT_HINT, vec![0])
+ })
+ .collect();
+ Obj::new(cls::VARYING_VALUE)
+ .data_prop(pid::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE.to_vec())
+ .with(
+ pid::INTERPOLATION,
+ weak(path::INTERPOLATION_DEFS, if *linear { def::INTERP_LINEAR } else { def::INTERP_CONSTANT }),
+ )
+ .with(pid::POINT_LIST, Value::StrongVec(pts))
+ }
+ };
+ Obj::new(cls::OPERATION_GROUP)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
+ .data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
+ .with(pid::OPERATION, weak(path::OPERATION_DEFS, def::MONO_AUDIO_GAIN))
+ .with(pid::INPUT_SEGMENTS, Value::StrongVec(vec![sc]))
+ .with(pid::PARAMETERS, Value::StrongVec(vec![param]))
+ }
+ _ => sc,
+ };
+ comps.push(seg);
+ cursor += c.len;
+ }
+ CItem::Transition(x) => {
+ let ts = cursor - x.len;
+ let n = w.u(cursor, rate) - w.u(ts, rate);
+ let cut = w.u(ts + x.cut, rate) - w.u(ts, rate);
+ cursor = ts;
+ let op = transition_op(t.kind, &x.effect);
+ if !used_ops.contains(&op) {
+ used_ops.push(op);
+ }
+ let mut og = Obj::new(cls::OPERATION_GROUP)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
+ .data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
+ .with(pid::OPERATION, weak(path::OPERATION_DEFS, op))
+ .with(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(vec![tagged("FilmCraft Effect", &x.effect)]));
+ if op == def::VIDEO_DISSOLVE {
+ uses_varying = true;
+ let pts = [(0, 0), (1, 1)]
+ .iter()
+ .map(|&(t, v)| {
+ Obj::new(cls::CONTROL_POINT)
+ .data_prop(pid::CP_TIME, rational(t, 1))
+ .data_prop(pid::CP_VALUE, indirect(def::TYPE_RATIONAL, &rational(v, 1)))
+ .data_prop(pid::CP_EDIT_HINT, vec![0])
+ })
+ .collect();
+ og.set(
+ pid::PARAMETERS,
+ Value::StrongVec(vec![
+ Obj::new(cls::VARYING_VALUE)
+ .data_prop(pid::PARAMETER_DEFINITION, def::PARAM_LEVEL.to_vec())
+ .with(pid::INTERPOLATION, weak(path::INTERPOLATION_DEFS, def::INTERP_LINEAR))
+ .with(pid::POINT_LIST, Value::StrongVec(pts)),
+ ]),
+ );
+ }
+ comps.push(
+ Obj::new(cls::TRANSITION)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
+ .data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
+ .data_prop(pid::CUT_POINT, cut.to_le_bytes().to_vec())
+ .with(pid::OPERATION_GROUP, Value::Strong(Box::new(og))),
+ );
+ }
+ }
+ }
+ let len: i64 = comps
+ .iter()
+ .map(|c| {
+ let l = c.i64(pid::LENGTH).unwrap_or(0);
+ if c.class == cls::TRANSITION { -l } else { l }
+ })
+ .sum();
+ let seq = Obj::new(cls::SEQUENCE)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
+ .data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
+ .with(pid::COMPONENTS, Value::StrongVec(comps));
+ slots.push(timeline_slot(slot_id, &t.name, t.number, rate, seq));
+ slot_id += 1;
+ }
+ if !comp.markers.is_empty() {
+ slots.push(event_slot(&mut w, slot_id, crate_rate, &comp.markers));
+ }
+ let comp_id = new_id();
+ mobs.insert(
+ 0,
+ Obj::new(cls::COMPOSITION_MOB)
+ .data_prop(pid::MOB_ID, comp_id.to_vec())
+ .data_prop(pid::MOB_NAME, utf16z(&comp.name))
+ .data_prop(pid::MOB_LAST_MODIFIED, timestamp())
+ .data_prop(pid::MOB_CREATION_TIME, timestamp())
+ .data_prop(pid::USAGE_CODE, def::USAGE_TOP_LEVEL.to_vec())
+ .with(pid::SLOTS, Value::StrongVec(slots))
+ .with(
+ pid::MOB_USER_COMMENTS,
+ Value::StrongVec(vec![
+ tagged("FilmCraft Frame Size", &format!("{}x{}", comp.width, comp.height)),
+ tagged("FilmCraft Audio Sample Rate", &comp.sample_rate.to_string()),
+ ]),
+ ),
+ );
+ if w.inexact {
+ w.report.info("some times are not on whole edit units and were rounded");
+ }
+
+ // ---- dictionary
+ let mut ddefs = Vec::new();
+ for (id, name) in [(ddef::PICTURE, "Picture"), (ddef::SOUND, "Sound"), (ddef::TIMECODE, "Timecode"), (ddef::DESCRIPTIVE_METADATA, "DescriptiveMetadata")] {
+ ddefs.push(definition(cls::DATA_DEFINITION, id, name));
+ }
+ let mut opdefs = Vec::new();
+ for op in &used_ops {
+ let (name, dd, inputs) = match *op {
+ x if x == def::VIDEO_DISSOLVE => ("Video Dissolve", ddef::PICTURE, 2),
+ x if x == def::VIDEO_FADE_TO_BLACK => ("Video Fade To Black", ddef::PICTURE, 1),
+ x if x == def::SMPTE_VIDEO_WIPE => ("SMPTE Video Wipe", ddef::PICTURE, 2),
+ x if x == def::MONO_AUDIO_DISSOLVE => ("Mono Audio Dissolve", ddef::SOUND, 2),
+ _ => ("Mono Audio Gain", ddef::SOUND, 1i32),
+ };
+ let params: Vec> = if *op == def::MONO_AUDIO_GAIN {
+ vec![def::PARAM_AMPLITUDE.to_vec()]
+ } else if *op == def::VIDEO_DISSOLVE {
+ vec![def::PARAM_LEVEL.to_vec()]
+ } else {
+ Vec::new()
+ };
+ let mut o = definition(cls::OPERATION_DEFINITION, *op, name)
+ .with(pid::OPDEF_DATA_DEFINITION, weak(path::DATA_DEFS, dd))
+ .data_prop(pid::OPDEF_IS_TIME_WARP, vec![0])
+ .data_prop(pid::OPDEF_NUMBER_INPUTS, inputs.to_le_bytes().to_vec());
+ if !params.is_empty() {
+ o.set(pid::OPDEF_PARAMETERS_DEFINED, Value::WeakVec(path::PARAMETER_DEFS.to_vec(), pid::IDENTIFICATION, params));
+ }
+ opdefs.push(o);
+ }
+ let pdefs = vec![
+ definition(cls::PARAMETER_DEFINITION, def::PARAM_LEVEL, "Level").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")),
+ definition(cls::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE, "Amplitude").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")),
+ ];
+ let idefs = if uses_varying || !used_ops.is_empty() {
+ vec![
+ definition(cls::INTERPOLATION_DEFINITION, def::INTERP_LINEAR, "Linear"),
+ definition(cls::INTERPOLATION_DEFINITION, def::INTERP_CONSTANT, "Constant"),
+ ]
+ } else {
+ Vec::new()
+ };
+ let cdefs = vec![
+ definition(cls::CONTAINER_DEFINITION, def::CONTAINER_EXTERNAL, "External Container"),
+ definition(cls::CONTAINER_DEFINITION, def::CONTAINER_AAF, "AAF Container"),
+ ];
+ let mut dict = Obj::new(cls::DICTIONARY)
+ .with(pid::DATA_DEFINITIONS, Value::StrongSet(ddefs, pid::IDENTIFICATION))
+ .with(pid::PARAMETER_DEFINITIONS, Value::StrongSet(pdefs, pid::IDENTIFICATION))
+ .with(pid::CONTAINER_DEFINITIONS, Value::StrongSet(cdefs, pid::IDENTIFICATION));
+ if !opdefs.is_empty() {
+ dict.set(pid::OPERATION_DEFINITIONS, Value::StrongSet(opdefs, pid::IDENTIFICATION));
+ }
+ if !idefs.is_empty() {
+ dict.set(pid::INTERPOLATION_DEFINITIONS, Value::StrongSet(idefs, pid::IDENTIFICATION));
+ }
+
+ let mut content = Obj::new(cls::CONTENT_STORAGE).with(pid::MOBS, Value::StrongSet(mobs, pid::MOB_ID));
+ if !essence.is_empty() {
+ content.set(pid::ESSENCE_DATA, Value::StrongSet(essence, pid::ESSENCE_MOB_ID));
+ }
+ let ident = Obj::new(cls::IDENTIFICATION)
+ .data_prop(pid::COMPANY_NAME, utf16z("FilmCraft"))
+ .data_prop(pid::PRODUCT_NAME, utf16z("FilmCraft"))
+ .data_prop(pid::PRODUCT_VERSION_STRING, utf16z(env!("CARGO_PKG_VERSION")))
+ .data_prop(pid::PRODUCT_ID, super::ids::guid(0x46494C4D, 0x4352, 0x4146, *b"T-AAF-EP").to_vec())
+ .data_prop(pid::DATE, timestamp())
+ .data_prop(pid::PLATFORM, utf16z("FilmCraft"))
+ .data_prop(pid::GENERATION_AUID, mob_id(seed, 0)[16..].to_vec());
+ let header = Obj::new(cls::HEADER)
+ .data_prop(pid::BYTE_ORDER, 0x4949i16.to_le_bytes().to_vec())
+ .data_prop(pid::LAST_MODIFIED, timestamp())
+ .data_prop(pid::VERSION, vec![1, 1])
+ .data_prop(pid::OBJECT_MODEL_VERSION, 1u32.to_le_bytes().to_vec())
+ .data_prop(pid::OPERATIONAL_PATTERN, def::OP_EDIT_PROTOCOL.to_vec())
+ .with(pid::CONTENT, Value::Strong(Box::new(content)))
+ .with(pid::DICTIONARY, Value::Strong(Box::new(dict)))
+ .with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident]));
+ let root = Obj::new(ROOT)
+ .with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY))))
+ .with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
+ store::write(&root, version).map_err(Error::Other)
+}
+
+fn slot_name(kind: CKind, n: u32) -> String {
+ match kind {
+ CKind::Picture => format!("V{n}"),
+ CKind::Sound => format!("A{n}"),
+ }
+}
+
+fn timeline_slot(id: u32, name: &str, number: u32, rate: (i64, i64), segment: Obj) -> Obj {
+ let mut o = Obj::new(cls::TIMELINE_MOB_SLOT)
+ .data_prop(pid::SLOT_ID, id.to_le_bytes().to_vec())
+ .data_prop(pid::SLOT_NAME, utf16z(name))
+ .data_prop(pid::EDIT_RATE, rational(rate.0, rate.1))
+ .data_prop(pid::ORIGIN, 0i64.to_le_bytes().to_vec())
+ .with(pid::SEGMENT, Value::Strong(Box::new(segment)));
+ if number > 0 {
+ o.set(pid::PHYSICAL_TRACK_NUMBER, Value::Data(number.to_le_bytes().to_vec()));
+ }
+ o
+}
+
+fn source_clip(kind: CKind, len: i64, source: [u8; 32], slot: u32, start: i64) -> Obj {
+ Obj::new(cls::SOURCE_CLIP)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, data_def(kind)))
+ .data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
+ .data_prop(pid::SOURCE_ID, source.to_vec())
+ .data_prop(pid::SOURCE_MOB_SLOT_ID, slot.to_le_bytes().to_vec())
+ .data_prop(pid::START_TIME, start.to_le_bytes().to_vec())
+}
+
+fn event_slot(w: &mut W, id: u32, rate: (i64, i64), markers: &[CMarker]) -> Obj {
+ let comps = markers
+ .iter()
+ .map(|m| {
+ let pos = w.u(m.start, rate);
+ let len = w.u(m.start + m.duration, rate) - pos;
+ let mut tags = vec![tagged("Name", &m.name), tagged("Comment", &m.comment)];
+ if let Some(c) = &m.color {
+ tags.push(tagged("Color", c));
+ }
+ Obj::new(cls::COMMENT_MARKER)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::DESCRIPTIVE_METADATA))
+ .data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
+ .data_prop(pid::POSITION, pos.to_le_bytes().to_vec())
+ .data_prop(pid::COMMENT, utf16z(if m.comment.is_empty() { &m.name } else { &m.comment }))
+ .with(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(tags))
+ })
+ .collect();
+ Obj::new(cls::EVENT_MOB_SLOT)
+ .data_prop(pid::SLOT_ID, id.to_le_bytes().to_vec())
+ .data_prop(pid::SLOT_NAME, utf16z("Markers"))
+ .data_prop(pid::EVENT_EDIT_RATE, rational(rate.0, rate.1))
+ .with(
+ pid::SEGMENT,
+ Value::Strong(Box::new(
+ Obj::new(cls::SEQUENCE)
+ .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::DESCRIPTIVE_METADATA))
+ .with(pid::COMPONENTS, Value::StrongVec(comps)),
+ )),
+ )
+}
+
+fn transition_op(kind: CKind, effect: &str) -> Auid {
+ match kind {
+ CKind::Sound => def::MONO_AUDIO_DISSOLVE,
+ CKind::Picture => match effect {
+ "dip_to_black" => def::VIDEO_FADE_TO_BLACK,
+ e if e.contains("wipe") => def::SMPTE_VIDEO_WIPE,
+ _ => def::VIDEO_DISSOLVE,
+ },
+ }
+}
+
+fn descriptor(w: &mut W, s: &Source) -> Obj {
+ let rate = source_rate(s);
+ let len = w.u(s.length, rate);
+ let container = if s.embedded.is_some() { def::CONTAINER_AAF } else { def::CONTAINER_EXTERNAL };
+ let mut d = match s.kind {
+ CKind::Picture => Obj::new(cls::CDCI_DESCRIPTOR)
+ .data_prop(pid::STORED_WIDTH, s.width.to_le_bytes().to_vec())
+ .data_prop(pid::STORED_HEIGHT, s.height.to_le_bytes().to_vec())
+ .data_prop(pid::FRAME_LAYOUT, vec![0])
+ .data_prop(pid::IMAGE_ASPECT_RATIO, rational(s.width.max(1) as i64, s.height.max(1) as i64))
+ .data_prop(pid::VIDEO_LINE_MAP, {
+ let mut v = 2u32.to_le_bytes().to_vec();
+ v.extend_from_slice(&4u32.to_le_bytes());
+ v.extend_from_slice(&0i32.to_le_bytes());
+ v.extend_from_slice(&0i32.to_le_bytes());
+ v
+ })
+ .data_prop(pid::COMPONENT_WIDTH, 8u32.to_le_bytes().to_vec())
+ .data_prop(pid::HORIZONTAL_SUBSAMPLING, 2u32.to_le_bytes().to_vec()),
+ CKind::Sound => {
+ let block = s.file_channels.max(1) * (s.bits as u32).div_ceil(8);
+ Obj::new(cls::PCM_DESCRIPTOR)
+ .data_prop(pid::AUDIO_SAMPLING_RATE, rational(s.sample_rate as i64, 1))
+ .data_prop(pid::LOCKED, vec![1])
+ .data_prop(pid::CHANNELS, s.file_channels.max(1).to_le_bytes().to_vec())
+ .data_prop(pid::QUANTIZATION_BITS, (s.bits as u32).to_le_bytes().to_vec())
+ .data_prop(pid::BLOCK_ALIGN, (block as u16).to_le_bytes().to_vec())
+ .data_prop(pid::AVERAGE_BPS, (block * s.sample_rate).to_le_bytes().to_vec())
+ }
+ };
+ d.set(pid::SAMPLE_RATE, Value::Data(rational(rate.0, rate.1)));
+ d.set(pid::FILE_LENGTH, Value::Data(len.to_le_bytes().to_vec()));
+ d.set(pid::CONTAINER_FORMAT, weak(path::CONTAINER_DEFS, container));
+ if let Some(p) = &s.path {
+ let url = if crate::common::is_absolute(p) { crate::common::path_to_file_url(p, false) } else { p.clone() };
+ d.set(pid::LOCATOR, Value::StrongVec(vec![Obj::new(cls::NETWORK_LOCATOR).data_prop(pid::URL_STRING, utf16z(&url))]));
+ }
+ d
+}
diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs
new file mode 100644
index 00000000..4731f206
--- /dev/null
+++ b/crates/interchange/src/comp.rs
@@ -0,0 +1,825 @@
+//! A format-neutral mob-style composition model shared by AAF and OMF.
+//!
+//! Both formats describe a timeline as a composition of slots (tracks) whose segments are
+//! sequences of fillers, source clips and transitions, where a transition *overlaps* the end of
+//! the segment before it and the start of the segment after it (the sequence's length is the sum of
+//! its segment lengths minus its transition lengths). Source clips reference media through master
+//! mobs and file source mobs. [`from_project`] turns a FilmCraft sequence into that shape;
+//! [`to_project`] builds FilmCraft sequences and media from it. All times are ticks; the format
+//! serialisers convert to and from edit units.
+
+use std::collections::HashMap;
+
+use filmcraft_media::MediaKind;
+use filmcraft_project::{
+ AudioChannels, ClipId, Interpolation, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, Sequence,
+ Track, TrackItem, TrackKind, Transition, TransitionAlign, TransitionId, find_effect,
+};
+use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
+
+use crate::common::{Builder, MediaSpec, base_item, db_to_gain, file_name, file_stem, gain_to_db, param, set_param, settings_for, transition_effect};
+use crate::essence::{EssenceData, EssenceKey, MediaOptions};
+use crate::{ImportOptions, Imported, Report};
+
+/// Picture or sound.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(crate) enum CKind {
+ Picture,
+ Sound,
+}
+
+/// A whole document: compositions and the media they reference.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub(crate) struct Document {
+ pub compositions: Vec,
+ pub sources: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct Composition {
+ pub name: String,
+ pub rate: FrameRate,
+ pub sample_rate: u32,
+ pub width: u32,
+ pub height: u32,
+ pub start_tc: i64,
+ pub drop: bool,
+ pub tracks: Vec,
+ pub markers: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct CTrack {
+ pub kind: CKind,
+ pub name: String,
+ /// Physical track number (1-based, per kind).
+ pub number: u32,
+ /// Sound: channels of the track (1 mono, 2 stereo, 6 5.1).
+ pub channels: u32,
+ pub items: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum CItem {
+ Filler(Tick),
+ Clip(CClip),
+ Transition(CTransition),
+}
+
+impl CItem {
+ pub fn len(&self) -> Tick {
+ match self {
+ CItem::Filler(l) => *l,
+ CItem::Clip(c) => c.len,
+ CItem::Transition(t) => t.len,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct CClip {
+ pub len: Tick,
+ /// Index into [`Document::sources`].
+ pub source: usize,
+ /// Source (file) time of the segment's first frame.
+ pub start: Tick,
+ pub gain: Option,
+ pub name: String,
+}
+
+/// Clip gain (linear amplitude).
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum Gain {
+ Constant(f64),
+ /// Points at offsets from the segment start; `linear` false = hold (step) interpolation.
+ Varying {
+ linear: bool,
+ points: Vec<(Tick, f64)>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct CTransition {
+ pub len: Tick,
+ /// Where the cut lies, from the transition start.
+ pub cut: Tick,
+ /// FilmCraft effect id.
+ pub effect: String,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct CMarker {
+ pub start: Tick,
+ pub duration: Tick,
+ pub name: String,
+ pub comment: String,
+ pub color: Option,
+}
+
+/// One essence: a file (or embedded data) carrying the picture, or some channels of sound, of a
+/// media item.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct Source {
+ /// Groups the sources of one media item (one master mob).
+ pub key: String,
+ pub name: String,
+ pub kind: CKind,
+ pub path: Option,
+ /// Sound: the (0-based) channel of the file this source exposes (broken out to mono), or
+ /// `None` for all of them.
+ pub channel: Option,
+ /// Sound: channels this source exposes (1 when `channel` is set, else `file_channels`).
+ pub channels: u32,
+ /// Sound: channels of the file / embedded data.
+ pub file_channels: u32,
+ pub width: u32,
+ pub height: u32,
+ /// Picture: the frame rate of the media.
+ pub frame_rate: FrameRate,
+ pub sample_rate: u32,
+ pub bits: u16,
+ pub length: Tick,
+ /// Media time of the essence's time 0 (trimmed / consolidated essence).
+ pub offset: Tick,
+ /// Start timecode in frames at `tc_rate`.
+ pub start_tc: Option,
+ pub tc_rate: FrameRate,
+ pub embedded: Option>,
+ pub markers: Vec,
+}
+
+impl Source {
+ pub fn is_still(&self) -> bool {
+ self.kind == CKind::Picture && self.length == Tick::ZERO
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// FilmCraft → composition
+// ---------------------------------------------------------------------------------------------
+
+/// How a transition sits on a clip.
+#[derive(Clone, Copy, Debug)]
+enum Side {
+ /// Shared with the adjacent clip: the transition's timeline range.
+ TwoSided(Tick, Tick),
+ /// Fade from / to nothing: the transition's length, anchored at the clip edge.
+ OneSided(Tick),
+}
+
+struct Exporter<'a> {
+ p: &'a Project,
+ media: &'a MediaOptions,
+ doc: Document,
+ memo: HashMap<(String, CKind, Option), usize>,
+ report: &'a mut Report,
+ seq_rate: FrameRate,
+}
+
+pub(crate) fn from_project(p: &Project, seq_id: ItemId, name: &str, media: &MediaOptions, report: &mut Report) -> crate::Result {
+ let seq = p.sequence(seq_id).ok_or(crate::Error::NoSequence(seq_id))?;
+ let rate = seq.settings.frame_rate;
+ let mut ex = Exporter { p, media, doc: Document::default(), memo: HashMap::new(), report, seq_rate: rate };
+ let mut comp = Composition {
+ name: name.to_string(),
+ rate,
+ sample_rate: seq.settings.sample_rate.max(1),
+ width: seq.settings.width,
+ height: seq.settings.height,
+ start_tc: seq.start_timecode,
+ drop: seq.settings.drop_frame,
+ tracks: Vec::new(),
+ markers: seq
+ .markers
+ .iter()
+ .map(|m| CMarker {
+ start: m.start,
+ duration: m.duration,
+ name: m.name.clone(),
+ comment: m.comment.clone(),
+ color: Some(m.color.name().to_string()),
+ })
+ .collect(),
+ };
+ if !media.audio_only {
+ if let Some(mix) = &media.mixdown_video {
+ let src = ex.doc.sources.len();
+ ex.doc.sources.push(Source {
+ key: "mixdown".into(),
+ name: format!("{name} (video mixdown)"),
+ kind: CKind::Picture,
+ path: Some(mix.path.clone()),
+ channel: None,
+ channels: 0,
+ file_channels: 0,
+ width: mix.width,
+ height: mix.height,
+ frame_rate: rate,
+ sample_rate: 0,
+ bits: 0,
+ length: mix.duration,
+ offset: Tick::ZERO,
+ start_tc: Some(seq.start_timecode + rate.frame_at(mix.start)),
+ tc_rate: rate,
+ embedded: None,
+ markers: Vec::new(),
+ });
+ let mut items = Vec::new();
+ if mix.start > Tick::ZERO {
+ items.push(CItem::Filler(mix.start));
+ }
+ items.push(CItem::Clip(CClip { len: mix.duration, source: src, start: Tick::ZERO, gain: None, name: format!("{name} (video mixdown)") }));
+ comp.tracks.push(CTrack { kind: CKind::Picture, name: "V1".into(), number: 1, channels: 0, items });
+ } else {
+ for (i, t) in seq.video_tracks.iter().enumerate() {
+ let items = ex.track(t, TrackKind::Video, None);
+ comp.tracks.push(CTrack { kind: CKind::Picture, name: format!("V{}", i + 1), number: i as u32 + 1, channels: 0, items });
+ }
+ }
+ }
+ let mut n = 0;
+ for t in &seq.audio_tracks {
+ let ch = match t.channels {
+ AudioChannels::Mono => 1,
+ AudioChannels::Surround51 => 6,
+ AudioChannels::Stereo | AudioChannels::Adaptive => 2,
+ };
+ if media.breakout_to_mono {
+ for c in 0..ch {
+ n += 1;
+ let items = ex.track(t, TrackKind::Audio, Some(c));
+ comp.tracks.push(CTrack { kind: CKind::Sound, name: format!("A{n}"), number: n, channels: 1, items });
+ }
+ } else {
+ n += 1;
+ let items = ex.track(t, TrackKind::Audio, None);
+ comp.tracks.push(CTrack { kind: CKind::Sound, name: format!("A{n}"), number: n, channels: ch, items });
+ }
+ }
+ ex.doc.compositions.push(comp);
+ Ok(ex.doc)
+}
+
+impl Exporter<'_> {
+ /// The source behind `c` on a track of `kind` (`sub`: broken-out channel slot), if exportable.
+ fn source_of(&mut self, c: &TrackItem, kind: TrackKind, sub: Option) -> Option {
+ let item = base_item(self.p, c.item);
+ let it = self.p.item(item)?;
+ let m = match &it.kind {
+ ItemKind::Media(m) => m,
+ ItemKind::Sequence(_) => {
+ self.report.warn("nested sequences are not exported (left as gaps); render and replace them first");
+ return None;
+ }
+ _ => {
+ self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)");
+ return None;
+ }
+ };
+ let path = match &m.media {
+ MediaRef::File { path } => path.clone(),
+ MediaRef::Generator(_) => {
+ self.report.warn("synthetic media (bars, colour mattes, …) is not exported (left as gaps)");
+ return None;
+ }
+ };
+ let ckind = if kind == TrackKind::Video { CKind::Picture } else { CKind::Sound };
+ // the source channel this slot plays when broken out to mono
+ let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s));
+ let clip_key = EssenceKey::Clip(c.id);
+ let media_key = EssenceKey::Media(item);
+ let ess = (ckind == CKind::Sound)
+ .then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel)))
+ .flatten()
+ .cloned();
+ let key = match &ess {
+ Some(e) if e.key == clip_key => format!("clip:{}", c.id.0),
+ _ => format!("item:{}", item.0),
+ };
+ let memo_key = (key.clone(), ckind, channel);
+ if let Some(&i) = self.memo.get(&memo_key) {
+ return Some(i);
+ }
+ let video = m.info.video.as_ref();
+ let audio = m.info.audio.as_ref();
+ let still = m.info.kind == MediaKind::Still;
+ match ckind {
+ CKind::Picture if video.is_none() && !still => return None,
+ CKind::Sound if audio.is_none() => return None,
+ _ => {}
+ }
+ let frame_rate = video.map(|v| v.frame_rate).filter(|r| r.num > 0).unwrap_or(self.seq_rate);
+ let mut markers: Vec = m.markers.iter().chain(c.markers.iter()).map(marker_out).collect();
+ markers.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
+ markers.dedup();
+ let mut s = Source {
+ key,
+ name: it.name.clone(),
+ kind: ckind,
+ path: Some(path.clone()),
+ channel: None,
+ channels: 0,
+ file_channels: 0,
+ width: video.map_or(0, |v| v.width),
+ height: video.map_or(0, |v| v.height),
+ frame_rate,
+ sample_rate: audio.map_or(48_000, |a| a.sample_rate.max(1)),
+ bits: audio.and_then(|a| a.bits_per_sample).unwrap_or(16).clamp(8, 32) as u16,
+ length: if still { Tick::ZERO } else { m.info.duration },
+ offset: Tick::ZERO,
+ start_tc: m.info.start_timecode,
+ tc_rate: frame_rate,
+ embedded: None,
+ markers,
+ };
+ if ckind == CKind::Sound {
+ s.file_channels = audio.map_or(2, |a| a.channels.max(1));
+ s.channel = channel;
+ if let Some(e) = ess {
+ s.sample_rate = e.sample_rate.max(1);
+ s.bits = e.bits;
+ s.file_channels = e.channels.max(1);
+ // per-channel essence is a mono file: its only channel
+ if e.channel.is_some() {
+ s.channel = None;
+ }
+ s.offset = e.start;
+ s.length = Tick::from_units(e.frames as i64, e.sample_rate.max(1) as i64);
+ match e.data {
+ EssenceData::Embedded(d) => {
+ s.path = None;
+ s.embedded = Some(d);
+ }
+ EssenceData::File { path } => s.path = Some(path),
+ }
+ if e.key == clip_key {
+ s.name = format!("{} (rendered)", c.name);
+ }
+ }
+ }
+ s.channels = if s.channel.is_some() { 1 } else { s.file_channels };
+ let i = self.doc.sources.len();
+ self.doc.sources.push(s);
+ self.memo.insert(memo_key, i);
+ Some(i)
+ }
+
+ fn gain_of(&mut self, c: &TrackItem, source: &Source) -> Option {
+ if source.key.starts_with("clip:") {
+ return None; // rendered: effects baked in
+ }
+ let g = c.gain_db;
+ match param(c, "volume", "level") {
+ Some(p) if p.is_animated() => {
+ let speed = if c.speed.abs() > 1e-9 { c.speed.abs() } else { 1.0 };
+ let linear = !p.keyframes.iter().all(|k| k.interp == Interpolation::Hold);
+ if p.keyframes.iter().any(|k| !matches!(k.interp, Interpolation::Linear | Interpolation::Hold)) {
+ self.report.info("Bezier volume keyframes were exported as linear");
+ }
+ let points = p
+ .keyframes
+ .iter()
+ .map(|k| {
+ let off = Tick(((k.time - c.source_in).0 as f64 / speed).round() as i64);
+ (off, db_to_gain(k.value.as_f64().unwrap_or(0.0) + g))
+ })
+ .collect();
+ Some(Gain::Varying { linear, points })
+ }
+ Some(p) => {
+ let db = p.value.as_f64().unwrap_or(0.0) + g;
+ (db.abs() > 1e-9).then(|| Gain::Constant(db_to_gain(db)))
+ }
+ None => (g.abs() > 1e-9).then(|| Gain::Constant(db_to_gain(g))),
+ }
+ }
+
+ fn track(&mut self, t: &Track, kind: TrackKind, sub: Option) -> Vec {
+ // exportable clips with their sources
+ let clips: Vec<(&TrackItem, usize)> = t.items.iter().filter_map(|c| self.source_of(c, kind, sub).map(|s| (c, s))).collect();
+ let idx_of: HashMap = clips.iter().enumerate().map(|(i, (c, _))| (c.id, i)).collect();
+ let mut tin: Vec