Merge branch 'main' into M3.13/M8.10

This commit is contained in:
Brandon Thomas 2026-10-03 11:50:01 -04:00
commit 0948dcf682
66 changed files with 10097 additions and 103 deletions

96
Cargo.lock generated
View File

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

View File

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

View File

@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **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.

View File

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

View File

@ -27,7 +27,7 @@ SUBCOMMANDS
export <out> [--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);
}
}

12
crates/cfb/Cargo.toml Normal file
View File

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

52
crates/cfb/README.md Normal file
View File

@ -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<u8> = 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.

118
crates/cfb/src/lib.rs Normal file
View File

@ -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<T> = std::result::Result<T, Error>;
/// 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<u16> = a.encode_utf16().collect();
let ub: Vec<u16> = 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;

333
crates/cfb/src/read.rs Normal file
View File

@ -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<usize>,
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<u32>,
minifat: Vec<u32>,
mini_stream: Vec<u8>,
pub entries: Vec<Entry>,
}
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<String>) -> 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<CompoundFile<'a>> {
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<u32> = (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<usize>) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<u32> = 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<usize> {
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<usize> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u16> = (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),
}
}

201
crates/cfb/src/tests.rs Normal file
View File

@ -0,0 +1,201 @@
use crate::*;
fn pattern(n: usize, seed: u8) -> Vec<u8> {
(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<u8>, Vec<(String, Vec<u8>)>) {
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<u8>)]) {
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);
}
}
}
}

341
crates/cfb/src/write.rs Normal file
View File

@ -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<u8>,
children: Vec<usize>,
}
/// Builds a compound file in memory.
#[derive(Clone, Debug)]
pub struct Writer {
version: Version,
nodes: Vec<Node>,
}
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<u8>) -> Result<NodeId> {
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<NodeId> {
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<u8>) -> Result<NodeId> {
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<u8> {
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<u16> = 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<u8>| {
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<u32> = (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]
}

View File

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

View File

@ -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<AudioPlan> {
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<f32>], bits: u16) -> Vec<u8> {
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<Vec<f32>> {
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<Vec<Vec<f32>>> {
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<f32>> = 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<Vec<Vec<f32>>> {
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<Mutex<Vec<u8>>> = 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<u8>| {
*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<AudioEssence>, Vec<String>)> {
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<u32>, Vec<&Vec<f32>>)> =
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<i32> = 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<MixdownVideo> {
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<u8>| 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<String> {
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<Value> {
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<Value> {
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<Value> {
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)
}

View File

@ -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>, 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<Vec<f32>> {
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<String> = 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<String> = 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::<usize>(), 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::<Vec<_>>(),
orig.audio_tracks[0].items.iter().map(|c| (c.start, c.duration)).collect::<Vec<_>>()
);
// 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);
}

View File

@ -752,6 +752,25 @@ fn build() -> Vec<CommandSpec> {
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<CommandSpec> {
"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
),

View File

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

View File

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

View File

@ -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<Format> {
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<ItemId> = 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<Value> {
"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();

View File

@ -8,6 +8,7 @@
//! The project is an `Arc<Project>` 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<Tick
}
}
#[cfg(test)]
mod aaf_omf_tests;
#[cfg(test)]
mod audio_effects_tests;
#[cfg(test)]

View File

@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
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)"),

View File

@ -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()));

View File

@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
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<Value> {
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());
}

View File

@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
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());

View File

@ -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"),

View File

@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
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);

View File

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

View File

@ -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<Mp4Writer<Out>>,
/// MXF exports write through this instead of `mux`.
mxf: Option<MxfMux>,
seq: ItemId,
project: Arc<Project>,
vt: usize,
at: Option<usize>,
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<Box<dyn VideoEncoder>> {
@ -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<Vec<Vec<f32>>>) -> 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<EncodedPacket>) -> 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<u8>, Vec<f32>)], f: i64) -> Result<Vec<EncodedPacket>> {

View File

@ -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> {
Out::create_path(settings, &settings.path)
}
/// An output file at `path` (OP-Atom exports write several).
fn create_path(settings: &ExportSettings, path: &str) -> Result<Out> {
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<u64> {
self.finish_path(settings, &settings.path)
}
fn finish_path(self, settings: &ExportSettings, path: &str) -> Result<u64> {
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<String>,
}
/// 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<Project>, 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;

View File

@ -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,
//! `<stem>_A1.mxf`, `<stem>_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<String> {
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<Out>,
/// OP-Atom: one writer per audio channel.
audio: Vec<(String, MxfWriter<Out>)>,
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<i64>,
max_display: i64,
frame_den: i64,
}
impl MxfMux {
pub fn new(s: MxfSetup) -> Result<Self> {
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<EncodedPacket>) -> 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<f32>]) -> 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<String>)> {
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))
}
}

View File

@ -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<Project>, 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<u8> {
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<String> = 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<u8> = 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");
}
}

View File

@ -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<ExportPreset> {
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",

View File

@ -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",
_ => "",
};

View File

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

View File

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

View File

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

View File

@ -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<String>,
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<u8>, 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<ExtractedMedia>, 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;

View File

@ -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| <Auid>::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<f64> {
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<MobId, &'a Obj>,
essence: HashMap<MobId, &'a [u8]>,
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<Document> {
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<MobId>) {
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<CItem> {
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<CItem> {
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| <Auid>::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| <Auid>::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<Gain> {
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| <Auid>::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<String>) -> 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<String>, 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::<Vec<_>>();
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<CItem>, 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<CItem>, 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<CMarker> {
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()
}

View File

@ -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<u8>),
Strong(Box<Obj>),
StrongVec(Vec<Obj>),
/// Objects keyed by the data property `key_pid` of each.
StrongSet(Vec<Obj>, u16),
/// (path of pids from the root to the target collection, key pid, key)
Weak(Vec<u16>, u16, Vec<u8>),
WeakVec(Vec<u16>, u16, Vec<Vec<u8>>),
Stream(Vec<u8>),
}
/// 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<u8>) -> 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<u8> {
self.data(pid).and_then(|d| d.first().copied())
}
pub fn u16(&self, pid: u16) -> Option<u16> {
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<u32> {
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<i64> {
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<String> {
self.data(pid).map(utf16_of)
}
pub fn auid(&self, pid: u16) -> Option<Auid> {
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<u8> {
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<u8> {
let mut v: Vec<u8> = 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<u16> = 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>) -> String {
s.into()
}
// ---------------------------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------------------------
struct W {
cfb: Writer,
paths: Vec<Vec<u16>>,
}
/// Serialise `root` (the Root object: MetaDictionary 0x0001, Header 0x0002) into a compound file.
pub(crate) fn write(root: &Obj, version: Version) -> Result<Vec<u8>, 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<u8>)> = 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<Vec<u16>>,
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<Obj, String> {
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<u16> {
b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn rd_u32(b: &[u8], at: usize) -> Option<u32> {
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<usize, String> {
self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\"")))
}
fn stream(&self, parent: usize, name: &str) -> Result<Vec<u8>, 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<Obj, String> {
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)
}
}

View File

@ -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<Vec<u8>> {
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::<HashSet<_>>().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<_>>(), 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(&amp[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<u16> = 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());
}

View File

@ -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<u8> {
// 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<u8> {
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<u8> {
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<Vec<u8>> {
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<Obj> = Vec::new();
let mut essence: Vec<Obj> = Vec::new();
let mut used_ops: Vec<Auid> = 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<usize>)> = 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<usize, ([u8; 32], u32)> = 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<String>, usize, CKind), [u8; 32], Vec<Obj>, 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<CMarker> = 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<Vec<u8>> = 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
}

View File

@ -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<Composition>,
pub sources: Vec<Source>,
}
#[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<CTrack>,
pub markers: Vec<CMarker>,
}
#[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<CItem>,
}
#[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<Gain>,
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<String>,
}
/// 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<String>,
/// Sound: the (0-based) channel of the file this source exposes (broken out to mono), or
/// `None` for all of them.
pub channel: Option<u32>,
/// 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<i64>,
pub tc_rate: FrameRate,
pub embedded: Option<Vec<u8>>,
pub markers: Vec<CMarker>,
}
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<u32>), 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<Document> {
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<u32>) -> Option<usize> {
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<CMarker> = 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<Gain> {
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<u32>) -> Vec<CItem> {
// 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<ClipId, usize> = clips.iter().enumerate().map(|(i, (c, _))| (c.id, i)).collect();
let mut tin: Vec<Option<(Side, &Transition)>> = vec![None; clips.len()];
let mut tout: Vec<Option<(Side, &Transition)>> = vec![None; clips.len()];
for tr in &t.transitions {
let a = tr.from.and_then(|f| idx_of.get(&f).copied());
let b = tr.to.and_then(|f| idx_of.get(&f).copied());
match (a, b) {
(Some(a), Some(b)) if b == a + 1 && clips[a].0.end() == clips[b].0.start && tr.start <= clips[b].0.start && tr.end() >= clips[a].0.end() => {
tout[a] = Some((Side::TwoSided(tr.start, tr.end()), tr));
tin[b] = Some((Side::TwoSided(tr.start, tr.end()), tr));
}
(_, Some(b)) if tr.from.is_none() || a.is_none() => {
let c = clips[b].0;
let len = (tr.end().min(c.end()) - tr.start.max(c.start)).max(Tick::ZERO);
if tr.start != c.start {
self.report.info("a fade-in transition not starting at its clip was moved to the clip start");
}
if len > Tick::ZERO {
tin[b] = Some((Side::OneSided(len), tr));
}
}
(Some(a), _) => {
let c = clips[a].0;
let len = (tr.end().min(c.end()) - tr.start.max(c.start)).max(Tick::ZERO);
if tr.end() != c.end() {
self.report.info("a fade-out transition not ending at its clip was moved to the clip end");
}
if len > Tick::ZERO {
tout[a] = Some((Side::OneSided(len), tr));
}
}
_ => self.report.warn("a transition without an exported clip was dropped"),
}
}
let mut out = Vec::new();
let mut cursor = Tick::ZERO;
let mut tail = Tick::ZERO; // the timeline must reach at least here (after a fade-out)
for (i, &(c, src)) in clips.iter().enumerate() {
let (s, e) = (c.start, c.end());
let mut seg_start = s;
match tin[i] {
Some((Side::TwoSided(ts, _), _)) => seg_start = ts,
Some((Side::OneSided(len), tr)) => {
let fill_to = s + len;
if fill_to > cursor {
out.push(CItem::Filler(fill_to - cursor));
cursor = fill_to;
}
out.push(CItem::Transition(CTransition { len, cut: Tick::ZERO, effect: tr.effect.effect.clone() }));
cursor -= len;
}
None => {
if s > cursor {
out.push(CItem::Filler(s - cursor));
}
}
}
let seg_end = match tout[i] {
Some((Side::TwoSided(_, te), _)) => te,
_ => e,
};
if c.speed != 1.0 || c.reverse || c.frame_hold.is_some() {
self.report.warn("speed changes, reverse playback and frame holds are not exported (clips play at 100%)");
}
let source = self.doc.sources[src].clone();
// clips play at 100% here, so timeline and media advance together
let start = c.source_in + (seg_start - s) - source.offset;
if start < Tick::ZERO {
self.report.warn("a clip uses media before the start of its (trimmed) essence");
}
let mut gain = if kind == TrackKind::Audio { self.gain_of(c, &source) } else { None };
if let Some(Gain::Varying { points, .. }) = &mut gain {
for p in points.iter_mut() {
p.0 += s - seg_start;
}
}
out.push(CItem::Clip(CClip { len: seg_end - seg_start, source: src, start, gain, name: c.name.clone() }));
cursor = seg_end;
match tout[i] {
Some((Side::TwoSided(ts, _), tr)) => {
out.push(CItem::Transition(CTransition { len: seg_end - ts, cut: e - ts, effect: tr.effect.effect.clone() }));
cursor = ts;
tail = tail.max(seg_end);
}
Some((Side::OneSided(len), tr)) => {
out.push(CItem::Transition(CTransition { len, cut: len, effect: tr.effect.effect.clone() }));
cursor = e - len;
tail = tail.max(e);
}
None => tail = tail.max(e),
}
}
if tail > cursor {
out.push(CItem::Filler(tail - cursor));
}
out
}
}
fn marker_out(m: &Marker) -> CMarker {
CMarker { start: m.start, duration: m.duration, name: m.name.clone(), comment: m.comment.clone(), color: Some(m.color.name().to_string()) }
}
fn marker_in(b: &mut Builder, m: &CMarker) -> Marker {
Marker {
id: MarkerId(b.alloc()),
start: m.start,
duration: m.duration,
name: m.name.clone(),
comment: m.comment.clone(),
kind: MarkerKind::Comment,
color: m.color.as_deref().and_then(Label::from_name).unwrap_or(Label::Forest),
}
}
// ---------------------------------------------------------------------------------------------
// Composition → FilmCraft
// ---------------------------------------------------------------------------------------------
/// Media a document embeds, to be written next to it by the caller (the item's path).
#[derive(Clone, Debug, PartialEq)]
pub struct ExtractedMedia {
/// The imported media item (fragment-local id) whose path is `path`.
pub item: ItemId,
pub path: String,
/// A complete WAV file.
pub wav: Vec<u8>,
}
pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Report) -> crate::Result<(Imported, Vec<ExtractedMedia>)> {
if doc.compositions.is_empty() {
return Err(crate::Error::Empty);
}
let name = opts.name.clone().unwrap_or_else(|| doc.compositions[0].name.clone());
let mut b = Builder::new(&name);
let media_bin = b.bin("Media", None);
let mut extracted = Vec::new();
// one media item per (key, path)
let mut item_of: HashMap<usize, ItemId> = HashMap::new();
let mut groups: Vec<((String, Option<String>), Vec<usize>)> = Vec::new();
for (i, s) in doc.sources.iter().enumerate() {
let k = (s.key.clone(), s.path.clone().or_else(|| s.embedded.is_some().then(|| format!("embedded:{i}"))));
match groups.iter_mut().find(|(g, _)| *g == k) {
Some((_, v)) => v.push(i),
None => groups.push((k, vec![i])),
}
}
let doc_stem = opts.name.clone().unwrap_or_else(|| "Imported".into());
let mut used_paths = std::collections::HashSet::new();
for ((_, path), members) in &groups {
let srcs: Vec<&Source> = members.iter().map(|&i| &doc.sources[i]).collect();
let first = srcs[0];
let pic = srcs.iter().find(|s| s.kind == CKind::Picture);
let snd = srcs.iter().find(|s| s.kind == CKind::Sound);
let mut spec = MediaSpec {
duration: Some(srcs.iter().map(|s| s.length).max().unwrap_or(Tick::ZERO)),
video: pic.map(|p| (p.width.max(1), p.height.max(1), p.frame_rate)),
audio: snd.map(|s| {
(s.sample_rate, srcs.iter().filter(|x| x.kind == CKind::Sound).map(|x| x.file_channels.max(x.channel.map_or(0, |c| c + 1))).max().unwrap_or(1))
}),
start_tc: first.start_tc.or_else(|| srcs.iter().find_map(|s| s.start_tc)),
kind: None,
};
if pic.is_some_and(|p| p.is_still()) {
spec.kind = Some(MediaKind::Still);
}
let embedded = snd.and_then(|s| s.embedded.as_ref().map(|d| (*s, d)));
let (resolved, display) = match (path.as_deref(), embedded) {
(_, Some((s, _))) => {
let safe: String = first.name.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
let base = format!("{doc_stem} Media/{}{}", safe.trim(), s.channel.map(|c| format!(" ch{}", c + 1)).unwrap_or_default());
let mut rel = format!("{base}.wav");
let mut n = 1;
while !used_paths.insert(rel.to_ascii_lowercase()) {
n += 1;
rel = format!("{base} ({n}).wav");
}
(crate::common::resolve_path(&rel, opts.base_dir.as_deref()), first.name.clone())
}
(Some(p), None) => {
(crate::common::resolve_path(p, opts.base_dir.as_deref()), if first.name.is_empty() { file_name(p).to_string() } else { first.name.clone() })
}
(None, None) => (String::new(), first.name.clone()),
};
let display = if display.is_empty() { file_stem(&resolved).to_string() } else { display };
let key = format!("{}|{}", first.key, resolved);
let id = b.file_media(&key, &display, &resolved, &spec, Some(media_bin));
if resolved.is_empty()
&& let Some(m) = b.p.item_mut(id).and_then(|i| i.as_media_mut())
{
m.offline = true;
report.warn(format!("\"{display}\" has no media location; imported offline"));
}
let mut markers: Vec<CMarker> = srcs.iter().flat_map(|s| s.markers.iter().cloned()).collect();
markers.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
markers.dedup();
let mk: Vec<Marker> = markers.iter().map(|m| marker_in(&mut b, m)).collect();
if let Some(m) = b.p.item_mut(id).and_then(|i| i.as_media_mut()) {
m.markers = mk;
}
if let Some((s, data)) = embedded {
extracted.push(ExtractedMedia {
item: id,
path: resolved.clone(),
wav: crate::wav::wav_file(data, s.file_channels.max(1) as u16, s.sample_rate, s.bits),
});
}
for &i in members {
item_of.insert(i, id);
}
}
for comp in &doc.compositions {
let mut settings = settings_for(comp.rate, comp.width.max(16), comp.height.max(16), comp.drop);
settings.sample_rate = comp.sample_rate.max(1);
let seq_id = b.reserve_sequence(&comp.name, settings.clone(), None);
let mut seq = crate::common::empty_sequence(settings);
seq.start_timecode = comp.start_tc;
seq.markers = comp.markers.iter().map(|m| marker_in(&mut b, m)).collect();
for t in &comp.tracks {
let kind = if t.kind == CKind::Picture { TrackKind::Video } else { TrackKind::Audio };
let idx = seq.tracks(kind).len();
let mut track = b.track(kind, idx);
if kind == TrackKind::Audio {
track.channels = match t.channels {
1 => AudioChannels::Mono,
6 => AudioChannels::Surround51,
_ => AudioChannels::Stereo,
};
}
build_track(&mut b, &mut track, kind, t, &doc.sources, &item_of, report);
seq.tracks_mut(kind).push(track);
}
if seq.video_tracks.is_empty() {
let t = b.track(TrackKind::Video, 0);
seq.video_tracks.push(t);
}
if seq.audio_tracks.is_empty() {
let t = b.track(TrackKind::Audio, 0);
seq.audio_tracks.push(t);
}
link_clips(&mut b, &mut seq);
b.put_sequence(seq_id, seq);
b.top.push(seq_id);
}
Ok((b.finish(), extracted))
}
enum Placed {
Gap,
Clip(usize),
Tr { start: Tick, len: Tick, cut: Tick, effect: String },
}
fn build_track(b: &mut Builder, track: &mut Track, kind: TrackKind, t: &CTrack, sources: &[Source], item_of: &HashMap<usize, ItemId>, report: &mut Report) {
let mut pos = Tick::ZERO;
let mut placed: Vec<Placed> = Vec::new();
let mut clips: Vec<TrackItem> = Vec::new();
for it in &t.items {
if pos.0.abs() > 4 * MAX_TIME.0 {
report.warn("a track longer than FilmCraft supports was cut short");
break;
}
match it {
CItem::Filler(l) => {
pos += *l;
placed.push(Placed::Gap);
}
CItem::Transition(tr) => {
pos -= tr.len;
placed.push(Placed::Tr { start: pos, len: tr.len, cut: tr.cut, effect: tr.effect.clone() });
}
CItem::Clip(c) => {
let Some(&item) = item_of.get(&c.source) else {
pos += c.len;
placed.push(Placed::Gap);
continue;
};
let mut ti = b.clip(item, kind, if c.name.is_empty() { &sources[c.source].name } else { &c.name }, pos, c.len, c.start);
if kind == TrackKind::Audio {
let src = &sources[c.source];
if t.channels == 1 {
ti.source_channels = vec![src.channel.unwrap_or(0) as u16];
}
if let Some(g) = &c.gain {
apply_gain(&mut ti, g);
}
}
placed.push(Placed::Clip(clips.len()));
clips.push(ti);
pos += c.len;
}
}
}
// resolve transitions against their neighbours
let mut transitions = Vec::new();
for k in 0..placed.len() {
let Placed::Tr { start, len, cut, effect } = &placed[k] else { continue };
let prev = k.checked_sub(1).and_then(|i| match placed[i] {
Placed::Clip(c) => Some(c),
_ => None,
});
let next = placed.get(k + 1).and_then(|p| match p {
Placed::Clip(c) => Some(*c),
_ => None,
});
let (start, len, cut) = (*start, *len, (*cut).clamp(Tick::ZERO, *len));
let cut_at = start + cut;
match (prev, next) {
(Some(a), Some(bi)) => {
let a_end = clips[a].end();
if a_end > cut_at {
clips[a].duration = cut_at - clips[a].start;
}
let shift = cut_at - clips[bi].start;
if shift > Tick::ZERO {
// keyframes are in media time: unaffected
clips[bi].start += shift;
clips[bi].source_in += shift;
clips[bi].duration -= shift;
}
}
(None, None) => {
report.warn("a transition between two fillers was dropped");
continue;
}
_ => {}
}
let align = if cut == Tick::ZERO {
TransitionAlign::StartAtCut
} else if cut == len {
TransitionAlign::EndAtCut
} else {
TransitionAlign::CenterAtCut
};
let audio = kind == TrackKind::Audio;
let effect = match find_effect(effect) {
Some(d) if d.kind == if audio { filmcraft_project::EffectKind::AudioTransition } else { filmcraft_project::EffectKind::VideoTransition } => {
d.instance()
}
_ => transition_effect(effect, audio, report),
};
transitions.push(Transition {
id: TransitionId(b.alloc()),
effect,
start,
duration: len,
from: prev.map(|a| clips[a].id),
to: next.map(|c| clips[c].id),
align,
reverse: false,
});
}
clips.retain(|c| c.duration > Tick::ZERO);
track.items = clips;
track.transitions = transitions;
track.sort();
}
fn apply_gain(ti: &mut TrackItem, g: &Gain) {
match g {
Gain::Constant(a) => set_param(ti, "volume", "level", Param::new(ParamValue::Float(gain_to_db(*a)))),
Gain::Varying { linear, points } => {
let mut p = Param::new(ParamValue::Float(points.first().map_or(0.0, |x| gain_to_db(x.1))));
for (off, a) in points {
let off = (*off).clamp(-MAX_TIME, MAX_TIME);
let mut k = Keyframe::new(ti.source_in + off, ParamValue::Float(gain_to_db(*a)));
k.interp = if *linear { Interpolation::Linear } else { Interpolation::Hold };
p.keyframes.push(k);
}
p.keyframes.sort_by_key(|k| k.time);
set_param(ti, "volume", "level", p);
}
}
}
/// Link video and audio clips of the same media that start and end together.
fn link_clips(b: &mut Builder, seq: &mut Sequence) {
let mut groups: HashMap<(ItemId, Tick, Tick, Tick), Vec<(TrackKind, usize, usize)>> = HashMap::new();
for kind in [TrackKind::Video, TrackKind::Audio] {
for (ti, t) in seq.tracks(kind).iter().enumerate() {
for (ci, c) in t.items.iter().enumerate() {
groups.entry((c.item, c.start, c.duration, c.source_in)).or_default().push((kind, ti, ci));
}
}
}
let mut keys: Vec<_> = groups.keys().copied().collect();
keys.sort_by_key(|k| (k.1, k.0.0, k.2, k.3));
for k in keys {
let g = &groups[&k];
if g.len() < 2 {
continue;
}
let l = b.link_id();
for &(kind, ti, ci) in g {
seq.tracks_mut(kind)[ti].items[ci].link = Some(l);
}
}
}
/// The largest time read from a document (about 3.3 days).
pub(crate) const MAX_TIME: Tick = Tick(1 << 56);
/// Ticks of `n` edit units at rate `num/den` (rounded to the nearest tick).
pub(crate) fn units_to_ticks(n: i64, num: i64, den: i64) -> Tick {
if num <= 0 || den <= 0 {
return Tick::ZERO;
}
let t = n as i128 * den as i128 * TICKS_PER_SECOND as i128;
let q = t.div_euclid(num as i128);
let r = t.rem_euclid(num as i128);
// documents are untrusted: keep every time far from overflow (±3 days)
Tick((q + if r * 2 >= num as i128 { 1 } else { 0 }).clamp(-MAX_TIME.0 as i128, MAX_TIME.0 as i128) as i64)
}
/// Edit units at rate `num/den` nearest to `t`; the flag is false when `t` is not on a unit.
pub(crate) fn ticks_to_units(t: Tick, num: i64, den: i64) -> (i64, bool) {
if num <= 0 || den <= 0 {
return (0, false);
}
let x = t.0 as i128 * num as i128;
let d = den as i128 * TICKS_PER_SECOND as i128;
let q = x.div_euclid(d);
let r = x.rem_euclid(d);
let n = q + if r * 2 >= d { 1 } else { 0 };
(n.clamp(i64::MIN as i128, i64::MAX as i128) as i64, r == 0)
}

View File

@ -0,0 +1,150 @@
//! Media options shared by the AAF and OMF exporters, and the audio essence the caller supplies.
//!
//! This crate does no file I/O and decodes no media. An export that embeds audio, consolidates
//! (trims) it into new files, renders clip effects or mixes the video down works in three steps:
//!
//! 1. [`audio_needs`] lists the audio ranges the document will reference (with handles);
//! 2. the caller (the engine) decodes / renders each range and writes or keeps the PCM;
//! 3. the caller passes the results as [`AudioEssence`] in [`MediaOptions::essence`] (and a
//! rendered video file as [`MediaOptions::mixdown_video`]) to [`crate::aaf::export`] /
//! [`crate::omf::export`].
use std::collections::BTreeMap;
use filmcraft_project::{ClipId, ItemId, ItemKind, MediaRef, Project, TrackKind};
use filmcraft_time::Tick;
use serde::{Deserialize, Serialize};
/// What a piece of essence stands in for: a whole media item, or one clip's rendered audio.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EssenceKey {
Media(ItemId),
Clip(ClipId),
}
/// One range of source audio an export references.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AudioNeed {
pub key: EssenceKey,
/// The media item (for a clip key: the clip's media).
pub item: ItemId,
/// The media file, when the item is file media.
pub path: Option<String>,
/// Media time range to supply (handles included, clamped to the media).
pub start: Tick,
pub end: Tick,
/// Channels of the media's audio.
pub channels: u32,
pub sample_rate: u32,
}
/// Where the samples of an [`AudioEssence`] are.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum EssenceData {
/// Interleaved little-endian signed PCM (`bits` per sample), embedded in the document.
Embedded(Vec<u8>),
/// A media file the caller wrote (consolidated / trimmed / rendered), referenced by path.
File { path: String },
}
/// Audio essence supplied by the caller for one [`EssenceKey`] (and channel, when broken out to
/// mono).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AudioEssence {
pub key: EssenceKey,
/// `Some(c)`: this essence carries only source channel `c` (0-based), as one mono stream.
pub channel: Option<u32>,
/// Media time of the first sample (trimmed essence starts after the media's 0).
pub start: Tick,
/// Sample frames.
pub frames: u64,
pub sample_rate: u32,
pub bits: u16,
pub channels: u32,
pub data: EssenceData,
/// Clip effects (volume, gain, audio effects) are rendered into these samples: the document
/// carries no gain for clips that use it.
pub effects_rendered: bool,
}
/// A rendered video mixdown that replaces every video track with one clip.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MixdownVideo {
pub path: String,
/// Timeline range the file covers (its first frame is at `start`).
pub start: Tick,
pub duration: Tick,
pub width: u32,
pub height: u32,
}
/// Options shared by the AAF and OMF exporters.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MediaOptions {
/// Split multichannel audio into mono tracks (one slot per channel; essence per channel).
pub breakout_to_mono: bool,
/// Leave the video tracks out (OMF is audio-only).
pub audio_only: bool,
/// Essence the caller prepared (see the module docs). Media without essence is referenced at
/// its original path.
pub essence: Vec<AudioEssence>,
/// Replace the video tracks with this rendered file.
pub mixdown_video: Option<MixdownVideo>,
}
impl MediaOptions {
/// Essence for `key` (and `channel` when broken out).
pub fn essence_for(&self, key: EssenceKey, channel: Option<u32>) -> Option<&AudioEssence> {
self.essence.iter().find(|e| e.key == key && (e.channel == channel || e.channel.is_none()))
}
}
/// How [`audio_needs`] groups ranges.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct NeedOptions {
/// Extra media before and after every used range.
pub handles: Tick,
/// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range
/// per media item covering all its uses ([`EssenceKey::Media`]).
pub per_clip: bool,
/// Whole media instead of the used range (embedding / copying without trimming).
pub whole_media: bool,
}
/// The audio ranges the audio tracks of `sequence` reference, in a stable order.
pub fn audio_needs(project: &Project, sequence: ItemId, opts: &NeedOptions) -> Vec<AudioNeed> {
let Some(seq) = project.sequence(sequence) else { return Vec::new() };
let mut by_item: BTreeMap<EssenceKey, AudioNeed> = BTreeMap::new();
for t in seq.tracks(TrackKind::Audio) {
for c in &t.items {
let item = crate::common::base_item(project, c.item);
let Some(m) = project.item(item).and_then(|i| match &i.kind {
ItemKind::Media(m) => Some(m),
_ => None,
}) else {
continue;
};
let Some(a) = m.info.audio.as_ref() else { continue };
let path = match &m.media {
MediaRef::File { path } => Some(path.clone()),
MediaRef::Generator(_) => None,
};
let dur = m.info.duration;
let (lo, hi) = if c.speed < 0.0 || c.reverse { (c.source_out(), c.source_in) } else { (c.source_in, c.source_out()) };
let (lo, hi) = (lo.min(hi), lo.max(hi));
let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles);
if dur > Tick::ZERO {
end = end.min(dur);
}
if opts.whole_media {
start = Tick::ZERO;
end = dur.max(end);
}
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) };
let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate });
e.start = e.start.min(start);
e.end = e.end.max(end);
}
}
by_item.into_values().collect()
}

View File

@ -5,7 +5,12 @@
//! drop-frame timecode ([`edl`]);
//! - **Final Cut Pro 7 XML** (`xmeml` v4/v5, the dialect Premiere Pro reads and writes) ([`fcp7`]);
//! - **FCPXML** 1.9–1.11 ([`fcpxml`]);
//! - **OpenTimelineIO** JSON (`.otio`) ([`otio`]).
//! - **OpenTimelineIO** JSON (`.otio`) ([`otio`]);
//! - **AAF** (`.aaf`, Edit Protocol, structured storage) ([`aaf`]);
//! - **OMF Interchange 2.0** (`.omf`, Bento) for audio post ([`omf`]).
//!
//! AAF and OMF can embed or consolidate audio: [`essence::audio_needs`] lists what the caller
//! (the engine) has to render, and [`essence::MediaOptions`] passes the result back in.
//!
//! Every importer returns a standalone [`Project`] fragment ([`Imported`]) that the caller merges into
//! the open project with [`merge_into`], plus a [`Report`] of everything that could not be represented
@ -18,13 +23,18 @@
//! This crate is L2: it does no file I/O. Media references are strings (absolute paths, or paths
//! resolved against the `base_dir` passed to the importer); the engine probes/relinks them.
pub mod aaf;
pub mod ale;
pub mod edl;
pub mod essence;
pub mod fcp7;
pub mod fcpxml;
pub mod omf;
pub mod otio;
mod common;
mod comp;
pub mod wav;
mod xml;
use std::collections::{BTreeMap, HashMap};
@ -34,6 +44,7 @@ use filmcraft_time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
pub use common::{file_url_to_path, path_to_file_url, resolve_path};
pub use comp::ExtractedMedia;
/// An interchange format.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -46,10 +57,14 @@ pub enum Format {
Fcpxml,
/// OpenTimelineIO JSON.
Otio,
/// Advanced Authoring Format (AAF Edit Protocol, structured storage).
Aaf,
/// OMF Interchange 2.0 (Bento container).
Omf,
}
impl Format {
pub const ALL: [Format; 4] = [Format::Edl, Format::Fcp7Xml, Format::Fcpxml, Format::Otio];
pub const ALL: [Format; 6] = [Format::Edl, Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf, Format::Omf];
pub fn name(self) -> &'static str {
match self {
@ -57,6 +72,8 @@ impl Format {
Format::Fcp7Xml => "Final Cut Pro XML",
Format::Fcpxml => "FCPXML",
Format::Otio => "OpenTimelineIO",
Format::Aaf => "AAF",
Format::Omf => "OMF",
}
}
@ -67,6 +84,8 @@ impl Format {
Format::Fcp7Xml => "xml",
Format::Fcpxml => "fcpxml",
Format::Otio => "otio",
Format::Aaf => "aaf",
Format::Omf => "omf",
}
}
@ -78,6 +97,8 @@ impl Format {
"xml" => Some(Format::Fcp7Xml),
"fcpxml" | "fcpxmld" => Some(Format::Fcpxml),
"otio" => Some(Format::Otio),
"aaf" => Some(Format::Aaf),
"omf" | "omfi" => Some(Format::Omf),
_ => None,
}
}
@ -85,6 +106,12 @@ impl Format {
/// Sniff the format of a document from its bytes, using the file extension as a hint.
pub fn detect(bytes: &[u8], extension: Option<&str>) -> Option<Format> {
if aaf::sniff(bytes) {
return Some(Format::Aaf);
}
if omf::sniff(bytes) && extension.is_none_or(|e| matches!(Format::from_extension(e), None | Some(Format::Omf))) {
return Some(Format::Omf);
}
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
let head = head.trim_start_matches('\u{feff}').trim_start();
if head.starts_with('<') {
@ -227,7 +254,16 @@ pub fn import(bytes: &[u8], format: Format, base_dir: Option<&str>) -> Result<(I
}
/// Import a document with explicit options.
///
/// AAF and OMF documents may embed audio: [`aaf::import`] / [`omf::import`] also return it.
pub fn import_with(bytes: &[u8], format: Format, opts: &ImportOptions) -> Result<(Imported, Report)> {
if matches!(format, Format::Aaf | Format::Omf) {
let (imported, extracted, mut report) = if format == Format::Aaf { aaf::import(bytes, opts)? } else { omf::import(bytes, opts)? };
if !extracted.is_empty() {
report.warn(format!("{} embedded audio file(s) were not extracted", extracted.len()));
}
return Ok((imported, report));
}
let text = decode_text(bytes);
let mut report = Report::default();
let imported = match format {
@ -235,6 +271,7 @@ pub fn import_with(bytes: &[u8], format: Format, opts: &ImportOptions) -> Result
Format::Fcp7Xml => fcp7::import(&text, opts, &mut report)?,
Format::Fcpxml => fcpxml::import(&text, opts, &mut report)?,
Format::Otio => otio::import(&text, opts, &mut report)?,
Format::Aaf | Format::Omf => unreachable!("handled above"),
};
Ok((imported, report))
}
@ -339,12 +376,19 @@ pub fn export(project: &Project, sequence: ItemId, format: Format, opts: &Export
if project.sequence(sequence).is_none() {
return Err(Error::NoSequence(sequence));
}
if format == Format::Aaf {
return aaf::export(project, sequence, &aaf::AafOptions { name: opts.name.clone(), ..Default::default() });
}
if format == Format::Omf {
return omf::export(project, sequence, &omf::OmfOptions { name: opts.name.clone(), ..Default::default() });
}
let mut report = Report::default();
let text = match format {
Format::Edl => edl::export(project, sequence, opts, &mut report)?,
Format::Fcp7Xml => fcp7::export(project, sequence, opts, &mut report)?,
Format::Fcpxml => fcpxml::export(project, sequence, opts, &mut report)?,
Format::Otio => otio::export(project, sequence, opts, &mut report)?,
Format::Aaf | Format::Omf => unreachable!("handled above"),
};
Ok((text.into_bytes(), report))
}

View File

@ -0,0 +1,357 @@
//! The Bento container (Apple, *Bento Specification* revision 1.0d5), the storage layer of OMF
//! Interchange 2.0 files.
//!
//! A Bento container is a heap of value bytes, a table of contents (TOC) describing every value of
//! every object, and a fixed-size label at the very end of the file that locates the TOC:
//!
//! ```text
//! label (24 bytes, big-endian): magic A4 'C' 'M' A5 'H' 'd' 'r' D7, u16 flags, u16 buffer size
//! (KiB), u16 major version (1), u16 minor version (0),
//! u32 TOC offset, u32 TOC size
//! ```
//!
//! Every value belongs to an (object id, property id, type id) triple. Properties and types are
//! themselves objects carrying a global name (`"OMFI:CPNT:Length"`, `"omfi:Length32"`). The TOC
//! is a stream of one-byte operations, each followed by its operands (big-endian):
//!
//! | op | operands | meaning |
//! |---|---|---|
//! | 1 NewObject | u32 id | following entries belong to this object |
//! | 2 NewProperty | u32 id | … to this property |
//! | 3 NewType | u32 id | starts a new value of this type |
//! | 4 ExplicitGen | u32 | generation of the value |
//! | 5 Offset4Len4 | u32 offset, u32 length | a value segment in the file |
//! | 6 ContdOffset4Len4 | u32 offset, u32 length | a continuation segment |
//! | 7 Offset8Len4 / 8 Contd… | u64 offset, u32 length | segments beyond 4 GiB |
//! | 9–13 Immediate0–4 | 4 bytes | a value of 0–4 bytes stored in the TOC |
//! | 14 ContdImmediate4 | 4 bytes | continuation |
//! | 15 ReferenceListID | u32 id | the object holding this value's reference list |
//! | 24 EndOfBufr | — | skip to the end of the current TOC buffer |
//! | 0xFF Padding | — | ignored |
//!
//! Object references (OMF's `omfi:ObjRef`) are 4-byte keys resolved through the referencing
//! object's reference list (standard property 22: pairs of u32 key, u32 object id). FilmCraft
//! uses the referenced object's id as the key.
use std::collections::{BTreeMap, HashMap};
pub(crate) const MAGIC: [u8; 8] = [0xA4, b'C', b'M', 0xA5, b'H', b'd', b'r', 0xD7];
pub(crate) const LABEL_SIZE: usize = 24;
// standard object ids
const TOC_OBJECT: u32 = 1;
const TOC_SEED: u32 = 2;
const TOC_MIN_SEED: u32 = 3;
const TOC_TYPE: u32 = 19;
const TYPE_7BIT_ASCII: u32 = 21;
pub(crate) const OBJ_REFERENCES: u32 = 22;
const GLOBAL_TYPE_NAME: u32 = 23;
const GLOBAL_PROP_NAME: u32 = 24;
const MIN_USER_ID: u32 = 100;
const OP_NEW_OBJECT: u8 = 1;
const OP_NEW_PROPERTY: u8 = 2;
const OP_NEW_TYPE: u8 = 3;
const OP_EXPLICIT_GEN: u8 = 4;
const OP_OFFSET4_LEN4: u8 = 5;
const OP_CONTD_OFFSET4_LEN4: u8 = 6;
const OP_OFFSET8_LEN4: u8 = 7;
const OP_CONTD_OFFSET8_LEN4: u8 = 8;
const OP_IMMEDIATE0: u8 = 9;
const OP_IMMEDIATE4: u8 = 13;
const OP_CONTD_IMMEDIATE4: u8 = 14;
const OP_REFERENCE_LIST_ID: u8 = 15;
const OP_END_OF_BUFFER: u8 = 24;
const OP_PADDING: u8 = 0xFF;
const TOC_BUFFER_KIB: u16 = 8;
#[derive(Clone, Debug, PartialEq, Eq)]
enum Seg {
Imm(Vec<u8>),
At(u64, u32),
}
/// Builds a Bento container.
pub(crate) struct Writer {
data: Vec<u8>,
names: HashMap<(u32, String), u32>,
next: u32,
/// (object, property, type, segments)
entries: Vec<(u32, u32, u32, Vec<Seg>)>,
}
impl Writer {
pub fn new() -> Writer {
Writer { data: Vec::new(), names: HashMap::new(), next: MIN_USER_ID, entries: Vec::new() }
}
fn named(&mut self, kind: u32, name: &str) -> u32 {
if let Some(&id) = self.names.get(&(kind, name.to_string())) {
return id;
}
let id = self.new_object();
let mut v = name.as_bytes().to_vec();
v.push(0);
let seg = self.store(&v);
self.entries.push((id, kind, TYPE_7BIT_ASCII, vec![seg]));
self.names.insert((kind, name.to_string()), id);
id
}
pub fn property(&mut self, name: &str) -> u32 {
self.named(GLOBAL_PROP_NAME, name)
}
pub fn type_id(&mut self, name: &str) -> u32 {
self.named(GLOBAL_TYPE_NAME, name)
}
pub fn new_object(&mut self) -> u32 {
let id = self.next;
self.next += 1;
id
}
fn store(&mut self, v: &[u8]) -> Seg {
if v.len() <= 4 {
return Seg::Imm(v.to_vec());
}
let at = self.data.len() as u64;
self.data.extend_from_slice(v);
Seg::At(at, v.len() as u32)
}
/// Set a value of `obj` (values of more than 4 GiB are not supported by OMF).
pub fn set(&mut self, obj: u32, property: &str, ty: &str, value: &[u8]) {
let p = self.property(property);
let t = self.type_id(ty);
let seg = self.store(value);
self.entries.push((obj, p, t, vec![seg]));
}
/// The reference list of `obj` (keys are object ids).
pub fn set_references(&mut self, obj: u32, targets: &[u32]) {
let mut v = Vec::with_capacity(targets.len() * 8);
for t in targets {
v.extend_from_slice(&t.to_be_bytes());
v.extend_from_slice(&t.to_be_bytes());
}
let seg = self.store(&v);
self.entries.push((obj, OBJ_REFERENCES, TYPE_7BIT_ASCII, vec![seg]));
}
pub fn finish(mut self) -> Vec<u8> {
let seed = self.next;
self.entries.push((TOC_OBJECT, TOC_SEED, TOC_TYPE, vec![Seg::Imm(seed.to_be_bytes().to_vec())]));
self.entries.push((TOC_OBJECT, TOC_MIN_SEED, TOC_TYPE, vec![Seg::Imm(MIN_USER_ID.to_be_bytes().to_vec())]));
self.entries.sort_by_key(|e| (e.0, e.1, e.2));
let mut toc = Vec::new();
let (mut cur_obj, mut cur_prop) = (u32::MAX, u32::MAX);
for (obj, prop, ty, segs) in &self.entries {
if *obj != cur_obj {
toc.push(OP_NEW_OBJECT);
toc.extend_from_slice(&obj.to_be_bytes());
cur_obj = *obj;
cur_prop = u32::MAX;
}
if *prop != cur_prop {
toc.push(OP_NEW_PROPERTY);
toc.extend_from_slice(&prop.to_be_bytes());
cur_prop = *prop;
}
toc.push(OP_NEW_TYPE);
toc.extend_from_slice(&ty.to_be_bytes());
for (i, s) in segs.iter().enumerate() {
match s {
Seg::Imm(b) => {
toc.push(if i > 0 { OP_CONTD_IMMEDIATE4 } else { OP_IMMEDIATE0 + b.len() as u8 });
let mut w = [0u8; 4];
w[..b.len()].copy_from_slice(b);
toc.extend_from_slice(&w);
}
Seg::At(off, len) if *off <= u32::MAX as u64 => {
toc.push(if i > 0 { OP_CONTD_OFFSET4_LEN4 } else { OP_OFFSET4_LEN4 });
toc.extend_from_slice(&(*off as u32).to_be_bytes());
toc.extend_from_slice(&len.to_be_bytes());
}
Seg::At(off, len) => {
toc.push(if i > 0 { OP_CONTD_OFFSET8_LEN4 } else { OP_OFFSET8_LEN4 });
toc.extend_from_slice(&off.to_be_bytes());
toc.extend_from_slice(&len.to_be_bytes());
}
}
}
}
let mut out = self.data;
let toc_at = out.len() as u64;
out.extend_from_slice(&toc);
let mut label = MAGIC.to_vec();
label.extend_from_slice(&0u16.to_be_bytes()); // flags
label.extend_from_slice(&TOC_BUFFER_KIB.to_be_bytes());
label.extend_from_slice(&1u16.to_be_bytes());
label.extend_from_slice(&0u16.to_be_bytes());
label.extend_from_slice(&(toc_at.min(u32::MAX as u64) as u32).to_be_bytes());
label.extend_from_slice(&(toc.len() as u32).to_be_bytes());
out.extend_from_slice(&label);
out
}
}
/// One value of a parsed object.
#[derive(Clone, Debug)]
pub(crate) struct Prop {
pub prop: u32,
#[allow(dead_code)]
pub ty: u32,
segs: Vec<Seg>,
}
/// A parsed Bento container.
pub(crate) struct Container<'a> {
data: &'a [u8],
pub objects: BTreeMap<u32, Vec<Prop>>,
names: HashMap<u32, String>,
by_name: HashMap<String, u32>,
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
/// Whether `b` ends with a Bento label.
pub(crate) fn sniff(b: &[u8]) -> bool {
b.len() >= LABEL_SIZE && b[b.len() - LABEL_SIZE..b.len() - LABEL_SIZE + 8] == MAGIC
}
impl<'a> Container<'a> {
pub fn open(data: &'a [u8]) -> Result<Container<'a>, String> {
if !sniff(data) {
return Err("no Bento label at the end of the file".into());
}
let l = &data[data.len() - LABEL_SIZE..];
let buf = (u16::from_be_bytes([l[10], l[11]]) as usize).max(1) * 1024;
let toc_at = be32(l, 16).unwrap_or(0) as usize;
let toc_len = be32(l, 20).unwrap_or(0) as usize;
let toc = data.get(toc_at..toc_at.checked_add(toc_len).ok_or("bad TOC size")?).ok_or("the TOC lies outside the file")?;
let mut c = Container { data, objects: BTreeMap::new(), names: HashMap::new(), by_name: HashMap::new() };
let (mut obj, mut prop) = (0u32, 0u32);
let mut i = 0;
while i < toc.len() {
let op = toc[i];
i += 1;
let need = |n: usize| -> Result<&[u8], String> { toc.get(i..i + n).ok_or_else(|| "truncated TOC".to_string()) };
match op {
OP_NEW_OBJECT => {
obj = be32(need(4)?, 0).unwrap_or(0);
i += 4;
}
OP_NEW_PROPERTY => {
prop = be32(need(4)?, 0).unwrap_or(0);
i += 4;
}
OP_NEW_TYPE => {
let ty = be32(need(4)?, 0).unwrap_or(0);
i += 4;
c.objects.entry(obj).or_default().push(Prop { prop, ty, segs: Vec::new() });
}
OP_EXPLICIT_GEN | OP_REFERENCE_LIST_ID => {
need(4)?;
i += 4;
}
OP_OFFSET4_LEN4 | OP_CONTD_OFFSET4_LEN4 => {
let b = need(8)?;
let seg = Seg::At(be32(b, 0).unwrap_or(0) as u64, be32(b, 4).unwrap_or(0));
i += 8;
c.push_seg(obj, seg)?;
}
OP_OFFSET8_LEN4 | OP_CONTD_OFFSET8_LEN4 => {
let b = need(12)?;
let off = u64::from_be_bytes(b[..8].try_into().map_err(|_| "truncated TOC")?);
let seg = Seg::At(off, be32(b, 8).unwrap_or(0));
i += 12;
c.push_seg(obj, seg)?;
}
x @ (OP_IMMEDIATE0..=OP_IMMEDIATE4 | OP_CONTD_IMMEDIATE4) => {
let b = need(4)?;
let n = if x == OP_CONTD_IMMEDIATE4 { 4 } else { (x - OP_IMMEDIATE0) as usize };
let seg = Seg::Imm(b[..n].to_vec());
i += 4;
c.push_seg(obj, seg)?;
}
OP_END_OF_BUFFER => {
// the rest of this TOC buffer is unused
i = (i - 1) / buf * buf + buf;
}
OP_PADDING | 0 => {}
other => return Err(format!("unknown TOC operation {other}")),
}
}
// global names
let ids: Vec<u32> = c.objects.keys().copied().collect();
for id in ids {
let name = c.objects[&id].iter().find(|p| p.prop == GLOBAL_PROP_NAME || p.prop == GLOBAL_TYPE_NAME).and_then(|p| c.value(p).ok());
if let Some(n) = name {
let s = String::from_utf8_lossy(&n).trim_end_matches('\0').to_string();
c.by_name.insert(s.clone(), id);
c.names.insert(id, s);
}
}
Ok(c)
}
fn push_seg(&mut self, obj: u32, seg: Seg) -> Result<(), String> {
let p = self.objects.get_mut(&obj).and_then(|v| v.last_mut()).ok_or("a value without a type in the TOC")?;
if p.segs.len() > 1 << 20 {
return Err("too many value segments".into());
}
p.segs.push(seg);
Ok(())
}
/// The bytes of a value.
pub fn value(&self, p: &Prop) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
for s in &p.segs {
match s {
Seg::Imm(b) => out.extend_from_slice(b),
Seg::At(off, len) => {
let start = usize::try_from(*off).map_err(|_| "value offset too large")?;
let b = self.data.get(start..start.checked_add(*len as usize).ok_or("bad value length")?).ok_or("a value lies outside the file")?;
out.extend_from_slice(b);
}
}
}
Ok(out)
}
pub fn id_of(&self, name: &str) -> Option<u32> {
self.by_name.get(name).copied()
}
#[allow(dead_code)]
pub fn name_of(&self, id: u32) -> Option<&str> {
self.names.get(&id).map(String::as_str)
}
/// The first value of property `name` of `obj`.
pub fn get(&self, obj: u32, name: &str) -> Option<Vec<u8>> {
let pid = self.id_of(name)?;
let p = self.objects.get(&obj)?.iter().find(|p| p.prop == pid)?;
self.value(p).ok()
}
/// Resolve an object reference key of `obj` through its reference list.
pub fn resolve(&self, obj: u32, key: u32) -> u32 {
if let Some(p) = self.objects.get(&obj).and_then(|v| v.iter().find(|p| p.prop == OBJ_REFERENCES))
&& let Ok(v) = self.value(p)
{
for pair in v.chunks_exact(8) {
if be32(pair, 0) == Some(key) {
return be32(pair, 4).unwrap_or(key);
}
}
}
key
}
}

View File

@ -0,0 +1,66 @@
//! OMF Interchange 2.0 export (and import of the same structures) for audio post.
//!
//! Written from the public *OMF Interchange Specification Version 2.0* (Avid Technology, 1995/97)
//! and Apple's *Bento Specification* revision 1.0d5 for the container ([`bento`]).
//!
//! The file holds a composition mob (`CMOB`) with a timecode slot and one sound slot per audio
//! track (one per channel when broken out to mono): sequences (`SEQU`) of fillers (`FILL`), source
//! clips (`SCLP`) and transitions (`TRAN` with a `SimpleMonoAudioDissolve` effect); clip volume is a
//! `MonoAudioGain` effect (`EFFE`) whose slot −1 is the clip and slot 1 the amplitude
//! (`CVAL` constant or `VVAL` keyframes). Every media item has a master mob (`MMOB`), a file source
//! mob (`SMOB` with a `WAVD` / `AIFD` descriptor: embedded essence in a `WAVE` media data object,
//! or a locator to a separate file) and a tape source mob carrying its timecode. Times are in
//! samples (sound slots use the sample rate as edit rate) so edits are sample-exact.
pub(crate) mod bento;
mod read;
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};
/// OMF export options (Premiere's OMF export dialog: the caller renders / consolidates the audio
/// and passes it in [`MediaOptions::essence`]).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OmfOptions {
/// Composition name (default: the sequence name).
pub name: Option<String>,
pub media: MediaOptions,
}
/// Export the audio of `sequence` as an OMF 2.0 file.
pub fn export(project: &Project, sequence: ItemId, opts: &OmfOptions) -> Result<(Vec<u8>, 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 mut media = opts.media.clone();
media.audio_only = true;
media.mixdown_video = None;
let doc = comp::from_project(project, sequence, &name, &media, &mut report)?;
if !doc.compositions[0].markers.is_empty() {
report.info("sequence markers are not written to OMF");
}
let bytes = write::write(&doc, &mut report)?;
Ok((bytes, report))
}
/// Import an OMF 2.0 file (as written by [`export`] and by applications using the same
/// structures). Embedded audio is returned as WAV files.
pub fn import(bytes: &[u8], opts: &ImportOptions) -> Result<(Imported, Vec<ExtractedMedia>, 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` end with a Bento container label (OMF files do).
pub fn sniff(bytes: &[u8]) -> bool {
bento::sniff(bytes)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,421 @@
//! OMF 2.0 objects (Bento) → composition model.
use std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, Tick};
use super::bento::Container;
use crate::comp::{CClip, CItem, CKind, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks};
use crate::{Error, Report, Result};
type Uid = [u8; 12];
struct R<'a, 'b> {
c: &'b Container<'a>,
report: &'b mut Report,
/// big-endian values ("MM"); "II" files are little-endian
be: bool,
mobs: HashMap<Uid, u32>,
media: HashMap<Uid, Vec<u8>>,
doc: Document,
sources: HashMap<(Uid, u32, String), usize>,
}
pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
let perr = |m: String| Error::Parse { format: "OMF", message: m };
let c = Container::open(bytes).map_err(perr)?;
let class_of = |o: u32| c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]]));
let head = c.objects.keys().copied().find(|&o| class_of(o) == Some(*b"HEAD")).ok_or_else(|| perr("no OMF header object".into()))?;
let be = c.get(head, "OMFI:HEAD:ByteOrder").is_none_or(|v| v.first() != Some(&0x49));
let mut r = R { c: &c, report, be, mobs: HashMap::new(), media: HashMap::new(), doc: Document::default(), sources: HashMap::new() };
let mob_list = r.refs(head, "OMFI:HEAD:Mobs");
let mob_list = if mob_list.is_empty() {
c.objects.keys().copied().filter(|&o| matches!(class_of(o).as_ref(), Some(b"CMOB" | b"MMOB" | b"SMOB"))).collect()
} else {
mob_list
};
for &m in &mob_list {
if let Some(id) = r.uid(m, "OMFI:MOBJ:MobID") {
r.mobs.insert(id, m);
}
}
let md = r.refs(head, "OMFI:HEAD:MediaData");
let md = if md.is_empty() { c.objects.keys().copied().filter(|&o| matches!(class_of(o).as_ref(), Some(b"WAVE" | b"AIFC"))).collect() } else { md };
for m in md {
let Some(id) = r.uid(m, "OMFI:MDAT:MobID") else { continue };
if let Some(d) = c.get(m, "OMFI:WAVE:Data") {
r.media.insert(id, d);
} else if let Some(d) = c.get(m, "OMFI:AIFC:Data") {
r.media.insert(id, d);
}
}
let cmobs: Vec<u32> = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect();
let mut referenced = HashSet::new();
for &m in &cmobs {
r.collect_refs(m, &mut referenced, 0);
}
let primary: Vec<u32> = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect();
let top: Vec<u32> = if !primary.is_empty() {
primary
} else {
cmobs.iter().copied().filter(|m| r.uid(*m, "OMFI:MOBJ:MobID").is_none_or(|id| !referenced.contains(&id))).collect()
};
for m in top {
let comp = r.composition(m);
r.doc.compositions.push(comp);
}
if r.doc.compositions.is_empty() {
return Err(Error::Empty);
}
Ok(r.doc)
}
impl R<'_, '_> {
fn class(&self, o: u32) -> [u8; 4] {
self.c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]])).unwrap_or(*b"????")
}
fn u32_of(&self, b: &[u8]) -> Option<u32> {
let a: [u8; 4] = b.get(..4)?.try_into().ok()?;
Some(if self.be { u32::from_be_bytes(a) } else { u32::from_le_bytes(a) })
}
fn i32(&self, o: u32, p: &str) -> Option<i64> {
self.c.get(o, p).and_then(|v| self.u32_of(&v)).map(|x| x as i32 as i64)
}
fn u32(&self, o: u32, p: &str) -> Option<u32> {
self.c.get(o, p).and_then(|v| self.u32_of(&v))
}
fn u16(&self, o: u32, p: &str) -> Option<u16> {
let v = self.c.get(o, p)?;
let a: [u8; 2] = v.get(..2)?.try_into().ok()?;
Some(if self.be { u16::from_be_bytes(a) } else { u16::from_le_bytes(a) })
}
fn rational(&self, o: u32, p: &str) -> Option<(i64, i64)> {
let v = self.c.get(o, p)?;
self.rational_of(&v)
}
fn rational_of(&self, v: &[u8]) -> Option<(i64, i64)> {
Some((self.u32_of(v)? as i32 as i64, self.u32_of(v.get(4..)?)? as i32 as i64))
}
fn string(&self, o: u32, p: &str) -> Option<String> {
self.c.get(o, p).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string())
}
fn uid(&self, o: u32, p: &str) -> Option<Uid> {
self.c.get(o, p).and_then(|v| v.get(..12).and_then(|b| b.try_into().ok()))
}
fn r#ref(&self, o: u32, p: &str) -> Option<u32> {
let v = self.c.get(o, p)?;
let key = u32::from_be_bytes(v.get(..4)?.try_into().ok()?);
Some(self.c.resolve(o, key))
}
fn refs(&self, o: u32, p: &str) -> Vec<u32> {
let Some(v) = self.c.get(o, p) else { return Vec::new() };
if v.len() < 2 {
return Vec::new();
}
let n = u16::from_be_bytes([v[0], v[1]]) as usize;
v[2..].chunks_exact(4).take(n).map(|k| self.c.resolve(o, u32::from_be_bytes([k[0], k[1], k[2], k[3]]))).collect()
}
fn data_kind(&self, o: u32) -> String {
self.r#ref(o, "OMFI:CPNT:DataKind").and_then(|d| self.string(d, "OMFI:DDEF:DataKindID")).unwrap_or_default()
}
fn len(&self, o: u32) -> i64 {
self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0)
}
fn collect_refs(&self, o: u32, out: &mut HashSet<Uid>, depth: usize) {
if depth > 64 {
return;
}
if self.class(o) == *b"SCLP"
&& let Some(id) = self.uid(o, "OMFI:SCLP:SourceID")
{
out.insert(id);
}
for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] {
for c in self.refs(o, p) {
self.collect_refs(c, out, depth + 1);
}
}
for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] {
if let Some(c) = self.r#ref(o, p) {
self.collect_refs(c, out, depth + 1);
}
}
}
fn composition(&mut self, m: u32) -> Composition {
let name = self.string(m, "OMFI:MOBJ:Name").unwrap_or_else(|| "Sequence".into());
let mut comp = Composition {
name,
rate: FrameRate::FPS_25,
sample_rate: 48_000,
width: 1920,
height: 1080,
start_tc: 0,
drop: false,
tracks: Vec::new(),
markers: Vec::new(),
};
let slots = self.refs(m, "OMFI:MOBJ:Slots");
let mut na = 0;
for s in slots {
let Some(seg) = self.r#ref(s, "OMFI:MSLT:Segment") else { continue };
let rate = self.rational(s, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((25, 1));
let kind = self.data_kind(seg);
if self.class(seg) == *b"TCCP" || kind == "omfi:data:Timecode" {
comp.rate = FrameRate::new(rate.0, rate.1);
if self.class(seg) == *b"TCCP" {
comp.start_tc = self.i32(seg, "OMFI:TCCP:Start").unwrap_or(0);
comp.drop = self.c.get(seg, "OMFI:TCCP:Drop").and_then(|v| v.first().copied()).unwrap_or(0) != 0;
}
continue;
}
let ckind = match kind.as_str() {
"omfi:data:Sound" => CKind::Sound,
"omfi:data:Picture" | "omfi:data:PictureWithMatte" => CKind::Picture,
_ => {
self.report.info("an OMF slot of an unsupported data kind was skipped");
continue;
}
};
if ckind == CKind::Picture {
self.report.info("OMF picture slots are not imported (audio only)");
continue;
}
if rate.1 == 1 && rate.0 >= 8000 {
comp.sample_rate = rate.0 as u32;
}
let items = self.segment(seg, rate, 0);
na += 1;
let td = self.r#ref(s, "OMFI:MSLT:TrackDesc");
let tname = td.and_then(|t| self.string(t, "OMFI:TRKD:TrackName")).unwrap_or_else(|| format!("A{na}"));
let number = td.and_then(|t| self.u32(t, "OMFI:TRKD:PhysicalTrack")).unwrap_or(na);
let channels = items
.iter()
.filter_map(|i| match i {
CItem::Clip(c) => Some(self.doc.sources[c.source].channels),
_ => None,
})
.max()
.unwrap_or(1)
.min(6);
comp.tracks.push(CTrack { kind: CKind::Sound, name: tname, number, channels, items });
}
comp
}
fn segment(&mut self, seg: u32, rate: (i64, i64), depth: usize) -> Vec<CItem> {
let len = units_to_ticks(self.len(seg), rate.0, rate.1);
if depth > 32 {
return vec![CItem::Filler(len)];
}
match &self.class(seg) {
b"SEQU" => {
let mut out = Vec::new();
for c in self.refs(seg, "OMFI:SEQU:Components") {
out.extend(self.segment(c, rate, depth + 1));
}
out
}
b"FILL" => vec![CItem::Filler(len)],
b"SCLP" => vec![self.clip(seg, rate)],
b"TRAN" => {
let cut = units_to_ticks(self.i32(seg, "OMFI:TRAN:CutPoint").unwrap_or(0), rate.0, rate.1);
let effect =
self.r#ref(seg, "OMFI:TRAN:Effect").and_then(|e| self.string(e, "FilmCraft:EFFE:EffectID")).unwrap_or_else(|| "constant_power".into());
vec![CItem::Transition(CTransition { len, cut: cut.clamp(Tick::ZERO, len), effect })]
}
b"EFFE" => {
let kind = self.r#ref(seg, "OMFI:EFFE:EffectKind").and_then(|k| self.string(k, "OMFI:EDEF:EffectID")).unwrap_or_default();
let slots = self.refs(seg, "OMFI:EFFE:EffectSlots");
let arg =
|r: &Self, id: i64| slots.iter().copied().find(|&s| r.i32(s, "OMFI:ESLT:ArgID") == Some(id)).and_then(|s| r.r#ref(s, "OMFI:ESLT:ArgValue"));
let Some(input) = arg(self, -1).or_else(|| arg(self, 1).filter(|_| kind != "omfi:effect:MonoAudioGain")) else {
return vec![CItem::Filler(len)];
};
let gain = if kind == "omfi:effect:MonoAudioGain" {
arg(self, 1).and_then(|v| self.gain(v, len))
} else {
self.report.warn("OMF effects other than audio gain are not imported (their input is used)");
None
};
let mut items = self.segment(input, rate, depth + 1);
if let (Some(g), [CItem::Clip(c)]) = (gain, items.as_mut_slice()) {
c.gain = Some(g);
}
items
}
_ => {
self.report.info("an unsupported OMF component was imported as a gap");
vec![CItem::Filler(len)]
}
}
}
fn amp(&self, v: &[u8]) -> Option<f64> {
self.rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64)
}
fn gain(&mut self, v: u32, len: Tick) -> Option<Gain> {
match &self.class(v) {
b"CVAL" => self.c.get(v, "OMFI:CVAL:Value").and_then(|d| self.amp(&d)).map(Gain::Constant),
b"VVAL" => {
let linear = self.u16(v, "OMFI:VVAL:Interpolation") != Some(1);
let points: Vec<(Tick, f64)> = self
.refs(v, "OMFI:VVAL:PointList")
.into_iter()
.filter_map(|p| {
let (n, d) = self.rational(p, "OMFI:CTLP:Time").filter(|r| r.1 != 0)?;
let a = self.c.get(p, "OMFI:CTLP:Value").and_then(|d| self.amp(&d))?;
Some((Tick((len.0 as i128 * n as i128 / d as i128).clamp(-(1i128 << 56), 1i128 << 56) as i64), a))
})
.collect();
(!points.is_empty()).then_some(Gain::Varying { linear, points })
}
_ => None,
}
}
fn clip(&mut self, c: u32, rate: (i64, i64)) -> CItem {
let len = units_to_ticks(self.len(c), rate.0, rate.1);
let start = units_to_ticks(self.i32(c, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);
let name = self.string(c, "FilmCraft:SCLP:ClipName").unwrap_or_default();
let (Some(id), Some(track)) = (self.uid(c, "OMFI:SCLP:SourceID"), self.u32(c, "OMFI:SCLP:SourceTrackID")) else { return CItem::Filler(len) };
if id == [0; 12] {
return CItem::Filler(len);
}
match self.resolve(id, track, start, None, 0) {
Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }),
None => CItem::Filler(len),
}
}
fn slot_of(&self, mob: u32, track: u32) -> Option<u32> {
self.refs(mob, "OMFI:MOBJ:Slots")
.into_iter()
.find(|&s| self.r#ref(s, "OMFI:MSLT:TrackDesc").and_then(|t| self.u32(t, "OMFI:TRKD:TrackID")) == Some(track))
}
fn first_clip(&self, seg: u32) -> Option<u32> {
match &self.class(seg) {
b"SCLP" => Some(seg),
b"SEQU" => self.refs(seg, "OMFI:SEQU:Components").into_iter().find_map(|c| self.first_clip(c)),
_ => None,
}
}
fn resolve(&mut self, id: Uid, track: u32, start: Tick, master: Option<String>, depth: usize) -> Option<(usize, Tick)> {
if depth > 16 {
return None;
}
let Some(&mob) = self.mobs.get(&id) else {
self.report.warn("a clip references a mob that is not in the file (imported as a gap)");
return None;
};
let slot = self.slot_of(mob, track)?;
let rate = self.rational(slot, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((48_000, 1));
let seg = self.r#ref(slot, "OMFI:MSLT:Segment")?;
match &self.class(mob) {
b"MMOB" => {
let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect());
let clip = self.first_clip(seg)?;
let inner = units_to_ticks(self.i32(clip, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);
let next = self.uid(clip, "OMFI:SCLP:SourceID")?;
let next_track = self.u32(clip, "OMFI:SCLP:SourceTrackID")?;
let name = self.string(mob, "OMFI:MOBJ:Name");
let r = self.resolve(next, next_track, start + inner, Some(key), depth + 1)?;
if let Some(n) = name {
self.doc.sources[r.0].name = n;
}
Some(r)
}
b"SMOB" => {
let desc = self.r#ref(mob, "OMFI:SMOB:MediaDescription")?;
let sr = self.rational(desc, "OMFI:MDFL:SampleRate");
if sr.is_none() && !self.media.contains_key(&id) {
return None; // a tape / physical source
}
let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect());
let skey = (id, track, key.clone());
if let Some(&i) = self.sources.get(&skey) {
return Some((i, start));
}
let file_rate = sr.filter(|r| r.0 > 0 && r.1 > 0).unwrap_or(rate);
let length = units_to_ticks(self.i32(desc, "OMFI:MDFL:Length").unwrap_or(0), file_rate.0, file_rate.1);
let summary = self.c.get(desc, "OMFI:WAVD:Summary").or_else(|| self.c.get(desc, "OMFI:AIFD:Summary")).unwrap_or_default();
let fmt = crate::wav::parse_wav(&summary).or_else(|| crate::wav::parse_aiff(&summary)).map(|(_, ch, sr, bits)| (ch as u32, sr, bits));
let mut path = None;
for l in self.refs(desc, "OMFI:MDES:Locator") {
let p = match &self.class(l) {
b"UNXL" => self.string(l, "OMFI:UNXL:PathName"),
b"DOSL" => self.string(l, "OMFI:DOSL:PathName"),
b"MACL" => self.string(l, "OMFI:MACL:PathName"),
b"TXTL" => self.string(l, "OMFI:TXTL:Name"),
b"NETL" => self.string(l, "OMFI:NETL:URLString").map(|u| crate::common::file_url_to_path(&u)),
_ => None,
};
if p.is_some() {
path = p;
break;
}
}
let sound_slots = self.refs(mob, "OMFI:MOBJ:Slots").len();
let (file_channels, sample_rate, bits) = fmt.unwrap_or((1, file_rate.0 as u32, 16));
let channel = (file_channels > 1 && sound_slots > 1).then(|| track.saturating_sub(1).min(file_channels - 1));
// timecode of the physical source
let tc = self.first_clip(seg).and_then(|clip| {
let tid = self.uid(clip, "OMFI:SCLP:SourceID")?;
let tape = *self.mobs.get(&tid)?;
let (start_tc, fps, trate) = self.refs(tape, "OMFI:MOBJ:Slots").into_iter().find_map(|s| {
let g = self.r#ref(s, "OMFI:MSLT:Segment")?;
(self.class(g) == *b"TCCP").then(|| {
(self.i32(g, "OMFI:TCCP:Start").unwrap_or(0), self.u16(g, "OMFI:TCCP:FPS").unwrap_or(25), self.rational(s, "OMFI:MSLT:EditRate"))
})
})?;
let tcr = trate.filter(|r| r.0 > 0 && r.1 > 0).map(|r| FrameRate::new(r.0, r.1)).unwrap_or_else(|| FrameRate::new(fps.max(1) as i64, 1));
let off = units_to_ticks(self.i32(clip, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);
Some((start_tc + tcr.frame_at(off), tcr))
});
let mut s = Source {
key,
name: self.string(mob, "OMFI:MOBJ:Name").unwrap_or_default(),
kind: CKind::Sound,
path,
channel,
channels: if channel.is_some() { 1 } else { file_channels },
file_channels,
width: 0,
height: 0,
frame_rate: FrameRate::FPS_25,
sample_rate,
bits,
length,
offset: Tick::ZERO,
start_tc: tc.map(|t| t.0),
tc_rate: tc.map(|t| t.1).unwrap_or(FrameRate::FPS_25),
embedded: None,
markers: Vec::new(),
};
if let Some(d) = self.media.get(&id) {
if let Some((pcm, ch, sr, b)) = crate::wav::parse_wav(d).or_else(|| crate::wav::parse_aiff(d)) {
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 = b;
} else {
self.report.warn("embedded OMF audio in an unsupported format was skipped");
}
}
let i = self.doc.sources.len();
self.doc.sources.push(s);
self.sources.insert(skey, i);
Some((i, start))
}
_ => {
self.report.warn("nested OMF compositions are not supported (imported as gaps)");
None
}
}
}
}

View File

@ -0,0 +1,192 @@
//! Structural checks of written OMF files: the Bento label and TOC, and the required properties of
//! the OMF Interchange 2.0 classes FilmCraft writes.
use std::collections::HashMap;
use filmcraft_project::{Project, SequenceSettings, TrackKind, TransitionAlign, TransitionId, find_effect};
use filmcraft_time::{FrameRate, TimeRange};
use super::bento::{self, Container};
use super::*;
use crate::essence::{AudioEssence, EssenceData, NeedOptions, audio_needs};
fn project() -> (Project, ItemId) {
let mut p = Project::new("t");
let info = filmcraft_media::MediaInfo {
name: "a.wav".into(),
kind: filmcraft_media::MediaKind::AudioOnly,
duration: FrameRate::FPS_25.tick_of(2500),
video: None,
audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(16) }),
container: String::new(),
start_timecode: None,
file_size: None,
};
let a = p.add_item(
"a.wav",
filmcraft_project::Label::Iris,
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
media: filmcraft_project::MediaRef::File { path: "/m/a.wav".into() },
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let r = FrameRate::FPS_25;
let s = p.new_sequence("S", SequenceSettings { frame_rate: r, ..Default::default() }, 1, 2, None);
let mut ids = Vec::new();
for (start, dur, src) in [(0, 50, 10), (50, 50, 300)] {
let ti = p.make_track_item(a, TrackKind::Audio, r.tick_of(start), TimeRange::new(r.tick_of(src), r.tick_of(dur)), r).unwrap();
ids.push(ti.id);
p.sequence_mut(s).unwrap().audio_tracks[0].items.push(ti);
}
let id = TransitionId(p.alloc_id());
let q = p.sequence_mut(s).unwrap();
q.audio_tracks[0].transitions.push(filmcraft_project::Transition {
id,
effect: find_effect("constant_power").unwrap().instance(),
start: r.tick_of(45),
duration: r.tick_of(10),
from: Some(ids[0]),
to: Some(ids[1]),
align: TransitionAlign::CenterAtCut,
reverse: false,
});
q.audio_tracks[0].items[0].gain_db = 6.0;
(p, s)
}
#[test]
fn written_files_have_the_required_structure() {
let (p, s) = project();
let needs = audio_needs(&p, s, &NeedOptions::default());
let essence = needs
.iter()
.map(|n| AudioEssence {
key: n.key,
channel: None,
start: n.start,
frames: 480,
sample_rate: 48_000,
bits: 16,
channels: 2,
data: EssenceData::Embedded(vec![7; 480 * 4]),
effects_rendered: false,
})
.collect();
let (bytes, _) = export(&p, s, &OmfOptions { media: MediaOptions { essence, ..Default::default() }, ..Default::default() }).unwrap();
// label
let l = &bytes[bytes.len() - bento::LABEL_SIZE..];
assert_eq!(&l[..8], &bento::MAGIC);
assert_eq!(u16::from_be_bytes([l[12], l[13]]), 1, "Bento major version");
let c = Container::open(&bytes).unwrap();
let class = |o: u32| c.get(o, "OMFI:OOBJ:ObjClass").map(|v| String::from_utf8_lossy(&v).to_string());
let mut by_class: HashMap<String, Vec<u32>> = HashMap::new();
for &o in c.objects.keys() {
if let Some(k) = class(o) {
by_class.entry(k).or_default().push(o);
}
}
let required: &[(&str, &[&str])] = &[
(
"HEAD",
&["OMFI:HEAD:ByteOrder", "OMFI:HEAD:LastModified", "OMFI:HEAD:Version", "OMFI:HEAD:Mobs", "OMFI:HEAD:MediaData", "OMFI:HEAD:DefinitionObjects"],
),
("CMOB", &["OMFI:MOBJ:MobID", "OMFI:MOBJ:Slots", "OMFI:MOBJ:LastModified", "OMFI:MOBJ:CreationTime"]),
("MMOB", &["OMFI:MOBJ:MobID", "OMFI:MOBJ:Slots", "OMFI:MOBJ:LastModified", "OMFI:MOBJ:CreationTime"]),
("SMOB", &["OMFI:MOBJ:MobID", "OMFI:MOBJ:Slots", "OMFI:SMOB:MediaDescription"]),
("MSLT", &["OMFI:MSLT:Segment", "OMFI:MSLT:EditRate", "OMFI:MSLT:TrackDesc"]),
("TRKD", &["OMFI:TRKD:Origin", "OMFI:TRKD:TrackID"]),
("SEQU", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:SEQU:Components"]),
("SCLP", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:SCLP:SourceID", "OMFI:SCLP:SourceTrackID", "OMFI:SCLP:StartTime"]),
("FILL", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length"]),
("TRAN", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:TRAN:CutPoint", "OMFI:TRAN:Effect"]),
("EFFE", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:EFFE:EffectKind"]),
("ESLT", &["OMFI:ESLT:ArgID", "OMFI:ESLT:ArgValue"]),
("CVAL", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:CVAL:Value"]),
("EDEF", &["OMFI:EDEF:EffectID"]),
("DDEF", &["OMFI:DDEF:DataKindID"]),
("TCCP", &["OMFI:CPNT:DataKind", "OMFI:CPNT:Length", "OMFI:TCCP:Start", "OMFI:TCCP:FPS", "OMFI:TCCP:Drop"]),
("WAVD", &["OMFI:MDFL:IsOMFI", "OMFI:MDFL:SampleRate", "OMFI:MDFL:Length", "OMFI:WAVD:Summary"]),
("WAVE", &["OMFI:MDAT:MobID", "OMFI:WAVE:Data"]),
("IDNT", &["OMFI:IDNT:CompanyName", "OMFI:IDNT:ProductName"]),
];
for (k, props) in required {
let Some(objs) = by_class.get(*k) else {
assert_eq!(*k, "FILL", "no {k} object");
continue;
};
for &o in objs {
for prop in *props {
assert!(c.get(o, prop).is_some(), "{k} {o} lacks {prop}");
}
}
}
assert_eq!(by_class["HEAD"].len(), 1);
assert_eq!(by_class["CMOB"].len(), 1);
let head = by_class["HEAD"][0];
assert_eq!(c.get(head, "OMFI:HEAD:Version"), Some(vec![2, 0]));
assert_eq!(c.get(head, "OMFI:HEAD:ByteOrder"), Some(b"MM".to_vec()));
// object references resolve to objects of the right class
let refs = |o: u32, p: &str| -> Vec<u32> {
let v = c.get(o, p).unwrap();
let n = u16::from_be_bytes([v[0], v[1]]) as usize;
v[2..].chunks_exact(4).take(n).map(|k| c.resolve(o, u32::from_be_bytes([k[0], k[1], k[2], k[3]]))).collect()
};
let one = |o: u32, p: &str| -> u32 { c.resolve(o, u32::from_be_bytes(c.get(o, p).unwrap()[..4].try_into().unwrap())) };
for m in refs(head, "OMFI:HEAD:Mobs") {
assert!(matches!(class(m).as_deref(), Some("CMOB" | "MMOB" | "SMOB")));
for s in refs(m, "OMFI:MOBJ:Slots") {
assert_eq!(class(s).as_deref(), Some("MSLT"));
assert_eq!(class(one(s, "OMFI:MSLT:TrackDesc")).as_deref(), Some("TRKD"));
}
}
// embedded media: WAVE data is a WAVE file of the file mob it names
for w in &by_class["WAVE"] {
let data = c.get(*w, "OMFI:WAVE:Data").unwrap();
let (pcm, ch, sr, bits) = crate::wav::parse_wav(&data).unwrap();
assert_eq!((pcm.len(), ch, sr, bits), (480 * 4, 2, 48_000, 16));
let id = c.get(*w, "OMFI:MDAT:MobID").unwrap();
assert!(by_class["SMOB"].iter().any(|s| c.get(*s, "OMFI:MOBJ:MobID") == Some(id.clone())));
}
// the composition's sound sequence: clip, transition, gain effect around a clip
let cm = by_class["CMOB"][0];
let slots = refs(cm, "OMFI:MOBJ:Slots");
assert_eq!(class(one(slots[0], "OMFI:MSLT:Segment")).as_deref(), Some("TCCP"));
let seq = one(slots[1], "OMFI:MSLT:Segment");
let comps = refs(seq, "OMFI:SEQU:Components");
let classes: Vec<String> = comps.iter().map(|&o| class(o).unwrap()).collect();
assert_eq!(classes, vec!["EFFE", "TRAN", "SCLP"]);
let len = |o: u32| i32::from_be_bytes(c.get(o, "OMFI:CPNT:Length").unwrap()[..4].try_into().unwrap()) as i64;
let samples = |f: i64| f * 48_000 / 25;
assert_eq!(len(comps[0]), samples(55));
assert_eq!(len(comps[1]), samples(10));
assert_eq!(len(comps[2]), samples(55));
assert_eq!(len(seq), samples(100));
assert_eq!(c.get(one(comps[0], "OMFI:EFFE:EffectKind"), "OMFI:EDEF:EffectID"), Some(b"omfi:effect:MonoAudioGain\0".to_vec()));
}
#[test]
fn bento_values_round_trip() {
let mut w = bento::Writer::new();
let a = w.new_object();
let b = w.new_object();
w.set(a, "Test:Small", "test:Bytes", &[1, 2]);
w.set(a, "Test:Empty", "test:Bytes", &[]);
w.set(b, "Test:Big", "test:Bytes", &vec![9; 100_000]);
w.set_references(a, &[b]);
let bytes = w.finish();
let c = Container::open(&bytes).unwrap();
assert_eq!(c.get(a, "Test:Small"), Some(vec![1, 2]));
assert_eq!(c.get(a, "Test:Empty"), Some(vec![]));
assert_eq!(c.get(b, "Test:Big").map(|v| v.len()), Some(100_000));
assert_eq!(c.resolve(a, b), b);
assert_eq!(c.name_of(c.id_of("Test:Big").unwrap()), Some("Test:Big"));
assert!(Container::open(&bytes[..bytes.len() - 1]).is_err());
}

View File

@ -0,0 +1,409 @@
//! Composition model → OMF 2.0 objects in a Bento container.
use std::collections::HashMap;
use filmcraft_time::Tick;
use super::bento;
use crate::comp::{CItem, CKind, Document, Gain, Source, ticks_to_units};
use crate::{Error, Report, Result};
/// An OMF mob id (`omfi:UID`): prefix, major, minor.
pub(crate) type Uid = [u8; 12];
pub(crate) fn uid(prefix: u32, major: u32, minor: u32) -> Uid {
let mut u = [0u8; 12];
u[..4].copy_from_slice(&prefix.to_be_bytes());
u[4..8].copy_from_slice(&major.to_be_bytes());
u[8..].copy_from_slice(&minor.to_be_bytes());
u
}
struct W<'a> {
b: bento::Writer,
refs: HashMap<u32, Vec<u32>>,
ddefs: HashMap<&'static str, u32>,
edefs: HashMap<&'static str, u32>,
defs: Vec<u32>,
report: &'a mut Report,
inexact: bool,
}
fn rational(n: i64, d: i64) -> Vec<u8> {
let mut v = (n.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_be_bytes().to_vec();
v.extend_from_slice(&(d.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_be_bytes());
v
}
fn cstr(s: &str) -> Vec<u8> {
let mut v = s.as_bytes().to_vec();
v.push(0);
v
}
impl W<'_> {
fn obj(&mut self, class: &[u8; 4]) -> u32 {
let id = self.b.new_object();
self.b.set(id, "OMFI:OOBJ:ObjClass", "omfi:ClassID", class);
id
}
fn str(&mut self, o: u32, p: &str, v: &str) {
self.b.set(o, p, "omfi:String", &cstr(v));
}
fn i32(&mut self, o: u32, p: &str, ty: &str, v: i64) {
if v > i32::MAX as i64 || v < i32::MIN as i64 {
self.report.warn("a time does not fit OMF's 32-bit positions and was clamped");
}
self.b.set(o, p, ty, &(v.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_be_bytes());
}
fn r#ref(&mut self, o: u32, p: &str, target: u32) {
self.b.set(o, p, "omfi:ObjRef", &target.to_be_bytes());
self.refs.entry(o).or_default().push(target);
}
fn refs(&mut self, o: u32, p: &str, targets: &[u32]) {
let mut v = (targets.len() as u16).to_be_bytes().to_vec();
for t in targets {
v.extend_from_slice(&t.to_be_bytes());
}
self.b.set(o, p, "omfi:ObjRefArray", &v);
self.refs.entry(o).or_default().extend_from_slice(targets);
}
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 ddef(&mut self, kind: &'static str) -> u32 {
if let Some(&d) = self.ddefs.get(kind) {
return d;
}
let d = self.obj(b"DDEF");
self.b.set(d, "OMFI:DDEF:DataKindID", "omfi:UniqueName", &cstr(kind));
self.ddefs.insert(kind, d);
self.defs.push(d);
d
}
fn edef(&mut self, id: &'static str, name: &str) -> u32 {
if let Some(&d) = self.edefs.get(id) {
return d;
}
let d = self.obj(b"EDEF");
self.b.set(d, "OMFI:EDEF:EffectID", "omfi:UniqueName", &cstr(id));
self.str(d, "OMFI:EDEF:EffectName", name);
self.b.set(d, "OMFI:EDEF:Bypass", "omfi:ArgIDType", &(-1i32).to_be_bytes());
self.edefs.insert(id, d);
self.defs.push(d);
d
}
fn cpnt(&mut self, o: u32, kind: &'static str, len: i64) {
let d = self.ddef(kind);
self.r#ref(o, "OMFI:CPNT:DataKind", d);
self.i32(o, "OMFI:CPNT:Length", "omfi:Length32", len);
}
fn mob(&mut self, class: &[u8; 4], id: Uid, name: &str, slots: &[u32]) -> u32 {
let m = self.obj(class);
self.b.set(m, "OMFI:MOBJ:MobID", "omfi:UID", &id);
self.str(m, "OMFI:MOBJ:Name", name);
self.refs(m, "OMFI:MOBJ:Slots", slots);
self.b.set(m, "OMFI:MOBJ:LastModified", "omfi:TimeStamp", &[0, 0, 0, 0, 1]);
self.b.set(m, "OMFI:MOBJ:CreationTime", "omfi:TimeStamp", &[0, 0, 0, 0, 1]);
m
}
fn slot(&mut self, rate: (i64, i64), segment: u32, track_id: u32, name: &str, physical: u32) -> u32 {
let t = self.obj(b"TRKD");
self.i32(t, "OMFI:TRKD:Origin", "omfi:Position32", 0);
self.b.set(t, "OMFI:TRKD:TrackID", "omfi:UInt32", &track_id.to_be_bytes());
self.str(t, "OMFI:TRKD:TrackName", name);
if physical > 0 {
self.b.set(t, "OMFI:TRKD:PhysicalTrack", "omfi:UInt32", &physical.to_be_bytes());
}
let s = self.obj(b"MSLT");
self.b.set(s, "OMFI:MSLT:EditRate", "omfi:Rational", &rational(rate.0, rate.1));
self.r#ref(s, "OMFI:MSLT:Segment", segment);
self.r#ref(s, "OMFI:MSLT:TrackDesc", t);
s
}
fn sclp(&mut self, kind: &'static str, len: i64, source: Uid, track: u32, start: i64) -> u32 {
let c = self.obj(b"SCLP");
self.cpnt(c, kind, len);
self.b.set(c, "OMFI:SCLP:SourceID", "omfi:UID", &source);
self.b.set(c, "OMFI:SCLP:SourceTrackID", "omfi:UInt32", &track.to_be_bytes());
self.i32(c, "OMFI:SCLP:StartTime", "omfi:Position32", start);
c
}
fn tccp(&mut self, len: i64, start: i64, fps: i64, drop: bool) -> u32 {
let t = self.obj(b"TCCP");
self.cpnt(t, "omfi:data:Timecode", len);
self.i32(t, "OMFI:TCCP:Start", "omfi:Position32", start);
self.b.set(t, "OMFI:TCCP:FPS", "omfi:UInt16", &(fps.clamp(1, u16::MAX as i64) as u16).to_be_bytes());
self.b.set(t, "OMFI:TCCP:Drop", "omfi:Boolean", &[drop as u8]);
t
}
fn value(&mut self, len: i64, g: &Gain, seg_len: i64, rate: (i64, i64), base: Tick) -> u32 {
match g {
Gain::Constant(a) => {
let v = self.obj(b"CVAL");
self.cpnt(v, "omfi:data:Rational", len);
self.b.set(v, "OMFI:CVAL:Value", "omfi:DataValue", &amp(*a));
v
}
Gain::Varying { linear, points } => {
let mut pts = Vec::new();
for (off, a) in points {
let o = self.u(base + *off, rate) - self.u(base, rate);
let p = self.obj(b"CTLP");
self.b.set(p, "OMFI:CTLP:Time", "omfi:Rational", &rational(o, seg_len.max(1)));
self.b.set(p, "OMFI:CTLP:Value", "omfi:DataValue", &amp(*a));
self.b.set(p, "OMFI:CTLP:EditHint", "omfi:EditHintType", &[0]);
pts.push(p);
}
let v = self.obj(b"VVAL");
self.cpnt(v, "omfi:data:Rational", len);
self.b.set(v, "OMFI:VVAL:Interpolation", "omfi:InterpKind", &(if *linear { 2u16 } else { 1u16 }).to_be_bytes());
self.refs(v, "OMFI:VVAL:PointList", &pts);
v
}
}
}
}
fn fnv(s: &[u8], h: u64) -> u64 {
s.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3))
}
fn amp(a: f64) -> Vec<u8> {
rational((a.max(0.0) * 65536.0).round().min(i32::MAX as f64) as i64, 65536)
}
fn rate_of(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),
}
}
pub(crate) fn write(doc: &Document, report: &mut Report) -> Result<Vec<u8>> {
let comp = doc.compositions.first().ok_or(Error::Empty)?;
let mut w = W { b: bento::Writer::new(), refs: HashMap::new(), ddefs: HashMap::new(), edefs: HashMap::new(), defs: Vec::new(), 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));
}
let major = (seed >> 32) as u32 ^ seed as u32;
let mut minor = 0u32;
let mut new_uid = || {
minor += 1;
uid(0x464C_4D43, major, minor)
};
let mut mobs = Vec::new();
let mut media_data = Vec::new();
let mut master_ref: HashMap<usize, (Uid, u32)> = HashMap::new();
// media
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
for (i, s) in doc.sources.iter().enumerate() {
if s.kind != CKind::Sound {
continue;
}
match groups.iter_mut().find(|(k, _)| *k == s.key) {
Some((_, v)) => v.push(i),
None => groups.push((s.key.clone(), vec![i])),
}
}
for (_, members) in &groups {
let first = &doc.sources[members[0]];
let master = new_uid();
let tc = members.iter().find_map(|&i| doc.sources[i].start_tc.map(|t| (t, doc.sources[i].tc_rate)));
let tape = new_uid();
let mut tape_slots = Vec::new();
if let Some((start, r)) = tc {
let len = members.iter().map(|&i| doc.sources[i].length).max().unwrap_or(Tick::ZERO);
let l = w.u(len, (r.num, r.den));
let t = w.tccp(l, start, r.timecode_base(), false);
tape_slots.push(w.slot((r.num, r.den), t, 1, "TC1", 0));
}
let mut master_slots = Vec::new();
for (k, &si) in members.iter().enumerate() {
let s = &doc.sources[si];
let rate = rate_of(s);
let len = w.u(s.length, rate);
// file mob
let file = new_uid();
let file_track = s.channel.map_or(1, |c| c + 1);
let tape_track = 2 + k as u32;
let to_tape = if tc.is_some() { w.sclp("omfi:data:Sound", len, tape, tape_track, 0) } else { w.sclp("omfi:data:Sound", len, uid(0, 0, 0), 0, 0) };
let fslot = w.slot(rate, to_tape, file_track, &format!("A{file_track}"), file_track);
let desc = descriptor(&mut w, s, len);
let fm = w.mob(b"SMOB", file, &s.name, &[fslot]);
w.r#ref(fm, "OMFI:SMOB:MediaDescription", desc);
mobs.push(fm);
if let Some(pcm) = &s.embedded {
let md = w.obj(b"WAVE");
w.b.set(md, "OMFI:MDAT:MobID", "omfi:UID", &file);
w.b.set(md, "OMFI:WAVE:Data", "omfi:DataValue", &crate::wav::wav_file(pcm, s.file_channels.max(1) as u16, s.sample_rate, s.bits));
media_data.push(md);
}
if tc.is_some() {
let c = w.sclp("omfi:data:Sound", len, uid(0, 0, 0), 0, 0);
tape_slots.push(w.slot(rate, c, tape_track, &format!("A{}", k + 1), k as u32 + 1));
}
let mclip = w.sclp("omfi:data:Sound", len, file, file_track, 0);
master_slots.push(w.slot(rate, mclip, k as u32 + 1, &format!("A{}", k + 1), k as u32 + 1));
master_ref.insert(si, (master, k as u32 + 1));
}
mobs.push(w.mob(b"MMOB", master, &first.name, &master_slots));
if tc.is_some() {
let d = w.obj(b"MDTP");
let reel = first.path.as_deref().map(crate::common::file_stem).unwrap_or(&first.name).to_string();
let tm = w.mob(b"SMOB", tape, &reel, &tape_slots);
w.r#ref(tm, "OMFI:SMOB:MediaDescription", d);
mobs.push(tm);
}
}
// composition
let crate_rate = (comp.rate.num, comp.rate.den);
let mut slots = Vec::new();
let total = comp
.tracks
.iter()
.map(|t| t.items.iter().fold(Tick::ZERO, |a, i| if matches!(i, CItem::Transition(_)) { a - i.len() } else { a + i.len() }))
.max()
.unwrap_or(Tick::ZERO);
let tl = w.u(total, crate_rate);
let tcc = w.tccp(tl, comp.start_tc, comp.rate.timecode_base(), comp.drop);
slots.push(w.slot(crate_rate, tcc, 1, "TC1", 0));
for (track_id, t) in (2..).zip(comp.tracks.iter().filter(|t| t.kind == CKind::Sound)) {
let rate = (comp.sample_rate.max(1) as i64, 1);
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;
let f = w.obj(b"FILL");
w.cpnt(f, "omfi:data:Sound", n);
comps.push(f);
}
CItem::Clip(c) => {
let n = w.u(cursor + c.len, rate) - w.u(cursor, rate);
let (mid, mtrack) = master_ref.get(&c.source).copied().unwrap_or((uid(0, 0, 0), 0));
let start = w.u(c.start, rate);
let sc = w.sclp("omfi:data:Sound", n, mid, mtrack, start);
if !c.name.is_empty() {
w.str(sc, "FilmCraft:SCLP:ClipName", &c.name);
}
let seg = match &c.gain {
Some(g) => {
let v = w.value(n, g, n, rate, cursor);
let input = w.obj(b"ESLT");
w.b.set(input, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &(-1i32).to_be_bytes());
w.r#ref(input, "OMFI:ESLT:ArgValue", sc);
let level = w.obj(b"ESLT");
w.b.set(level, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &1i32.to_be_bytes());
w.r#ref(level, "OMFI:ESLT:ArgValue", v);
let kind = w.edef("omfi:effect:MonoAudioGain", "Mono Audio Gain");
let e = w.obj(b"EFFE");
w.cpnt(e, "omfi:data:Sound", n);
w.r#ref(e, "OMFI:EFFE:EffectKind", kind);
w.refs(e, "OMFI:EFFE:EffectSlots", &[input, level]);
e
}
None => 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 kind = w.edef("omfi:effect:SimpleMonoAudioDissolve", "Simple Mono Audio Dissolve");
let e = w.obj(b"EFFE");
w.cpnt(e, "omfi:data:Sound", n);
w.r#ref(e, "OMFI:EFFE:EffectKind", kind);
w.refs(e, "OMFI:EFFE:EffectSlots", &[]);
w.str(e, "FilmCraft:EFFE:EffectID", &x.effect);
let tr = w.obj(b"TRAN");
w.cpnt(tr, "omfi:data:Sound", n);
w.i32(tr, "OMFI:TRAN:CutPoint", "omfi:Position32", cut);
w.r#ref(tr, "OMFI:TRAN:Effect", e);
comps.push(tr);
}
}
}
let len = w.u(cursor.max(Tick::ZERO), rate);
let seq = w.obj(b"SEQU");
w.cpnt(seq, "omfi:data:Sound", len);
w.refs(seq, "OMFI:SEQU:Components", &comps);
slots.push(w.slot(rate, seq, track_id, &t.name, t.number));
}
let cmob = w.mob(b"CMOB", new_uid(), &comp.name, &slots);
mobs.insert(0, cmob);
if w.inexact {
w.report.info("some times are not on whole samples / frames and were rounded");
}
let ident = w.obj(b"IDNT");
w.str(ident, "OMFI:IDNT:CompanyName", "FilmCraft");
w.str(ident, "OMFI:IDNT:ProductName", "FilmCraft");
w.str(ident, "OMFI:IDNT:ProductVersionString", env!("CARGO_PKG_VERSION"));
w.str(ident, "OMFI:IDNT:Platform", "FilmCraft");
w.b.set(ident, "OMFI:IDNT:Date", "omfi:TimeStamp", &[0, 0, 0, 0, 1]);
let head = w.obj(b"HEAD");
w.b.set(head, "OMFI:HEAD:ByteOrder", "omfi:Int16", &0x4D4Di16.to_be_bytes());
w.b.set(head, "OMFI:HEAD:LastModified", "omfi:TimeStamp", &[0, 0, 0, 0, 1]);
w.b.set(head, "OMFI:HEAD:Version", "omfi:VersionType", &[2, 0]);
w.refs(head, "OMFI:HEAD:Mobs", &mobs);
w.refs(head, "OMFI:HEAD:MediaData", &media_data);
w.refs(head, "OMFI:HEAD:PrimaryMobs", &[cmob]);
let defs = w.defs.clone();
w.refs(head, "OMFI:HEAD:DefinitionObjects", &defs);
w.refs(head, "OMFI:HEAD:IdentificationList", &[ident]);
let refs = std::mem::take(&mut w.refs);
let mut keys: Vec<_> = refs.keys().copied().collect();
keys.sort_unstable();
for k in keys {
let mut t = refs[&k].clone();
t.sort_unstable();
t.dedup();
w.b.set_references(k, &t);
}
Ok(w.b.finish())
}
fn descriptor(w: &mut W, s: &Source, len: i64) -> u32 {
let aiff = s.embedded.is_none() && s.path.as_deref().is_some_and(|p| matches!(crate::common::extension(p).as_str(), "aif" | "aiff" | "aifc"));
let d = w.obj(if aiff { b"AIFD" } else { b"WAVD" });
w.b.set(d, "OMFI:MDFL:IsOMFI", "omfi:Boolean", &[s.embedded.is_some() as u8]);
let r = rate_of(s);
w.b.set(d, "OMFI:MDFL:SampleRate", "omfi:Rational", &rational(r.0, r.1));
w.i32(d, "OMFI:MDFL:Length", "omfi:Length32", len);
let frames = len.max(0) as u32;
let bytes_per_frame = s.file_channels.max(1) * (s.bits as u32).div_ceil(8);
if aiff {
w.b.set(d, "OMFI:AIFD:Summary", "omfi:DataValue", &crate::wav::aiff_header(frames, s.file_channels.max(1) as u16, s.sample_rate, s.bits));
} else {
w.b.set(
d,
"OMFI:WAVD:Summary",
"omfi:DataValue",
&crate::wav::wav_header(frames.saturating_mul(bytes_per_frame), s.file_channels.max(1) as u16, s.sample_rate, s.bits),
);
}
if let Some(p) = &s.path {
let mut locs = Vec::new();
let dos = p.len() > 2 && p.as_bytes()[1] == b':';
let l = w.obj(if dos { b"DOSL" } else { b"UNXL" });
w.str(l, if dos { "OMFI:DOSL:PathName" } else { "OMFI:UNXL:PathName" }, p);
locs.push(l);
if crate::common::is_absolute(p) {
let n = w.obj(b"NETL");
w.str(n, "OMFI:NETL:URLString", &crate::common::path_to_file_url(p, false));
locs.push(n);
}
w.refs(d, "OMFI:MDES:Locator", &locs);
}
d
}

View File

@ -0,0 +1,180 @@
//! Minimal WAVE (RIFF) and AIFF containers for audio essence embedded in AAF / OMF documents.
//! PCM inside the model is interleaved little-endian signed integers (8-bit unsigned for WAVE).
/// A complete WAVE file around little-endian PCM.
pub fn wav_file(pcm: &[u8], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let mut v = wav_header(pcm.len() as u32, channels, sample_rate, bits);
v.extend_from_slice(pcm);
if pcm.len() % 2 == 1 {
v.push(0);
}
v
}
/// The RIFF header of a WAVE file up to and including the `data` chunk header.
pub fn wav_header(data_len: u32, channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let block = channels.max(1) * bits.div_ceil(8);
let mut v = Vec::with_capacity(44);
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&(36 + data_len + (data_len & 1)).to_le_bytes());
v.extend_from_slice(b"WAVEfmt ");
v.extend_from_slice(&16u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&channels.max(1).to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&(sample_rate * block as u32).to_le_bytes());
v.extend_from_slice(&block.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
v.extend_from_slice(b"data");
v.extend_from_slice(&data_len.to_le_bytes());
v
}
/// PCM, channels, sample rate and bits of a WAVE file (integer PCM only).
pub fn parse_wav(b: &[u8]) -> Option<(Vec<u8>, u16, u32, u16)> {
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
return None;
}
let mut at = 12;
let mut fmt = None;
while at + 8 <= b.len() {
let id = &b[at..at + 4];
let len = u32::from_le_bytes(b[at + 4..at + 8].try_into().ok()?) as usize;
let body = &b[at + 8..(at + 8).saturating_add(len).min(b.len())];
if id == b"fmt " && body.len() >= 16 {
let tag = u16::from_le_bytes([body[0], body[1]]);
let ch = u16::from_le_bytes([body[2], body[3]]);
let sr = u32::from_le_bytes(body[4..8].try_into().ok()?);
let bits = u16::from_le_bytes([body[14], body[15]]);
if tag != 1 && tag != 0xFFFE {
return None;
}
fmt = Some((ch, sr, bits));
} else if id == b"data" {
let (ch, sr, bits) = fmt?;
return Some((body.to_vec(), ch, sr, bits));
}
at = at.checked_add(8 + len + (len & 1))?;
}
None
}
/// A complete AIFF file (big-endian PCM) from little-endian PCM.
pub fn aiff_file(pcm: &[u8], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let bps = bits.div_ceil(8) as usize;
let frames = pcm.len() / (bps * channels.max(1) as usize).max(1);
let mut v = aiff_header(frames as u32, channels, sample_rate, bits);
for s in pcm.chunks_exact(bps.max(1)) {
if bps == 1 {
v.push(s[0] ^ 0x80); // WAVE 8-bit is unsigned, AIFF signed
} else {
v.extend(s.iter().rev());
}
}
if (v.len() - 8) % 2 == 1 {
v.push(0);
}
v
}
/// The AIFF header up to the sound data (`FORM`, `COMM`, `SSND` with offset and block size).
pub fn aiff_header(frames: u32, channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let data_len = frames as usize * channels.max(1) as usize * bits.div_ceil(8) as usize;
let mut v = Vec::with_capacity(54);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&((4 + 26 + 16 + data_len + (data_len & 1)) as u32).to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
v.extend_from_slice(&channels.max(1).to_be_bytes());
v.extend_from_slice(&frames.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate as f64));
v.extend_from_slice(b"SSND");
v.extend_from_slice(&((8 + data_len) as u32).to_be_bytes());
v.extend_from_slice(&[0; 8]);
v
}
/// PCM (converted to little-endian), channels, sample rate and bits of an AIFF / AIFF-C (`NONE`
/// / `twos` / `sowt`) file.
pub fn parse_aiff(b: &[u8]) -> Option<(Vec<u8>, u16, u32, u16)> {
if b.len() < 12 || &b[..4] != b"FORM" || !(&b[8..12] == b"AIFF" || &b[8..12] == b"AIFC") {
return None;
}
let mut at = 12;
let mut comm = None;
while at + 8 <= b.len() {
let id = &b[at..at + 4];
let len = u32::from_be_bytes(b[at + 4..at + 8].try_into().ok()?) as usize;
let body = &b[at + 8..(at + 8).saturating_add(len).min(b.len())];
if id == b"COMM" && body.len() >= 18 {
let ch = u16::from_be_bytes([body[0], body[1]]);
let bits = u16::from_be_bytes([body[6], body[7]]);
let sr = from_extended(body[8..18].try_into().ok()?).round() as u32;
let little = body.len() >= 22 && &body[18..22] == b"sowt";
comm = Some((ch, sr, bits, little));
} else if id == b"SSND" && body.len() >= 8 {
let (ch, sr, bits, little) = comm?;
let off = u32::from_be_bytes(body[..4].try_into().ok()?) as usize;
let data = body.get(8 + off..)?;
let bps = bits.div_ceil(8) as usize;
let mut pcm = Vec::with_capacity(data.len());
for s in data.chunks_exact(bps.max(1)) {
if bps == 1 {
pcm.push(s[0] ^ 0x80);
} else if little {
pcm.extend_from_slice(s);
} else {
pcm.extend(s.iter().rev());
}
}
return Some((pcm, ch, sr, bits));
}
at = at.checked_add(8 + len + (len & 1))?;
}
None
}
/// 80-bit IEEE 754 extended (AIFF sample rates).
fn extended(x: f64) -> [u8; 10] {
let mut out = [0u8; 10];
if x <= 0.0 {
return out;
}
let e = x.log2().floor() as i32;
let m = (x / 2f64.powi(e) * (1u64 << 63) as f64) as u64;
out[..2].copy_from_slice(&((e + 16383) as u16).to_be_bytes());
out[2..].copy_from_slice(&m.to_be_bytes());
out
}
fn from_extended(b: [u8; 10]) -> f64 {
let e = (u16::from_be_bytes([b[0], b[1]]) & 0x7FFF) as i32;
let m = u64::from_be_bytes(b[2..10].try_into().unwrap_or([0; 8]));
if e == 0 && m == 0 {
return 0.0;
}
m as f64 / (1u64 << 63) as f64 * 2f64.powi(e - 16383)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wav_and_aiff_round_trip() {
let pcm: Vec<u8> = (0..48u8).collect(); // 8 stereo 24-bit frames
let w = wav_file(&pcm, 2, 48_000, 24);
assert_eq!(parse_wav(&w), Some((pcm.clone(), 2, 48_000, 24)));
let a = aiff_file(&pcm, 2, 44_100, 24);
assert_eq!(parse_aiff(&a), Some((pcm.clone(), 2, 44_100, 24)));
let p16: Vec<u8> = (0..32u8).collect();
assert_eq!(parse_aiff(&aiff_file(&p16, 1, 96_000, 16)), Some((p16, 1, 96_000, 16)));
assert!(parse_wav(b"RIFF").is_none());
assert!(parse_aiff(&a[..30]).is_none());
for cut in 0..w.len() {
let _ = parse_wav(&w[..cut]);
let _ = parse_aiff(&a[..cut.min(a.len())]);
}
}
}

View File

@ -0,0 +1,450 @@
//! AAF and OMF: export → import round trips (no external oracle exists for these formats).
mod support;
use filmcraft_interchange::aaf::{self, AafOptions};
use filmcraft_interchange::essence::{AudioEssence, EssenceData, MediaOptions, MixdownVideo, NeedOptions, audio_needs};
use filmcraft_interchange::omf::{self, OmfOptions};
use filmcraft_interchange::{Format, ImportOptions, Imported, detect};
use filmcraft_project::{
AudioChannels, Interpolation, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind,
TransitionAlign,
};
use filmcraft_time::{FrameRate, Tick};
use support::*;
/// V1: a fade-in from black, a cross dissolve, a gap, a clip with a dip-to-black fade-out.
/// V2: one clip. A1/A2: the linked audio with a crossfade, constant gain and volume keyframes.
fn sample(rate: FrameRate, df: bool) -> (Project, ItemId) {
let mut p = Project::new("p");
let a = media(&mut p, "/media/A001.mov", true, true, rate);
let b = media(&mut p, "/media/B002.mov", true, true, rate);
let m = media(&mut p, "/media/music.wav", false, true, rate);
if let Some(mm) = p.item_mut(a).and_then(|i| i.as_media_mut()) {
mm.info.start_timecode = Some(rate.timecode_base() * 3600 + 12);
mm.markers.push(Marker {
id: MarkerId(9000),
start: rate.tick_of(40),
duration: Tick::ZERO,
name: "slate".into(),
comment: "take 3".into(),
kind: MarkerKind::Comment,
color: Label::Rose,
});
}
let s = sequence(&mut p, "Edit 1", rate, df);
let v1 = clip(&mut p, s, TrackKind::Video, 0, a, 0, 100, 20);
let v2 = clip(&mut p, s, TrackKind::Video, 0, b, 100, 80, 50);
let v3 = clip(&mut p, s, TrackKind::Video, 0, a, 220, 60, 300);
clip(&mut p, s, TrackKind::Video, 1, b, 30, 40, 0);
transition(&mut p, s, TrackKind::Video, 0, "cross_dissolve", None, Some(v1), 0, 12, TransitionAlign::StartAtCut);
transition(&mut p, s, TrackKind::Video, 0, "cross_dissolve", Some(v1), Some(v2), 90, 20, TransitionAlign::CenterAtCut);
transition(&mut p, s, TrackKind::Video, 0, "dip_to_black", Some(v3), None, 266, 14, TransitionAlign::EndAtCut);
let a1 = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 100, 20);
let a2 = clip(&mut p, s, TrackKind::Audio, 0, b, 100, 80, 50);
let a3 = clip(&mut p, s, TrackKind::Audio, 0, a, 220, 60, 300);
transition(&mut p, s, TrackKind::Audio, 0, "constant_gain", Some(a1), Some(a2), 95, 10, TransitionAlign::CenterAtCut);
let mu = clip(&mut p, s, TrackKind::Audio, 1, m, 10, 200, 0);
link(&mut p, s, &[v1, a1]);
link(&mut p, s, &[v2, a2]);
link(&mut p, s, &[v3, a3]);
{
let q = p.sequence_mut(s).unwrap();
q.start_timecode = rate.timecode_base() * 3600;
q.markers.push(Marker {
id: MarkerId(9001),
start: rate.tick_of(50),
duration: rate.tick_of(10),
name: "Fix this".into(),
comment: "colour".into(),
kind: MarkerKind::Comment,
color: Label::Mango,
});
let (_, c) = q.find_item_mut(a2).unwrap();
c.effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.0)));
let (_, c) = q.find_item_mut(mu).unwrap();
let mut prm = Param::new(ParamValue::Float(0.0));
for (f, db) in [(0, -12.0), (48, 0.0), (150, -3.0)] {
let mut k = Keyframe::new(rate.tick_of(f), ParamValue::Float(db));
k.interp = Interpolation::Linear;
prm.keyframes.push(k);
}
c.effect_mut("volume").unwrap().params.insert("level".into(), prm);
}
(p, s)
}
fn level(p: &Project, seq: ItemId, track: usize, clip: usize) -> Param {
p.sequence(seq).unwrap().audio_tracks[track].items[clip]
.effect("volume")
.and_then(|e| e.param("level"))
.cloned()
.unwrap_or(Param::new(ParamValue::Float(0.0)))
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-3
}
fn aaf_rt(p: &Project, s: ItemId, opts: &AafOptions) -> (Imported, Vec<filmcraft_interchange::ExtractedMedia>, filmcraft_interchange::Report) {
let (bytes, _) = aaf::export(p, s, opts).expect("export");
assert!(aaf::sniff(&bytes));
assert_eq!(detect(&bytes, Some("aaf")), Some(Format::Aaf));
aaf::import(&bytes, &ImportOptions { base_dir: Some("/proj".into()), name: Some("rt".into()), ..Default::default() }).expect("import")
}
#[test]
fn aaf_round_trip_preserves_the_edit() {
for (rate, df) in [(FrameRate::FPS_25, false), (FrameRate::FPS_29_97, true), (FrameRate::FPS_23_976, false), (FrameRate::FPS_59_94, true)] {
let (p, s) = sample(rate, df);
let (imp, extracted, rep) = aaf_rt(&p, s, &AafOptions::default());
assert!(extracted.is_empty());
let n = only_seq(&imp);
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video), "{rate:?}: {rep}");
assert_eq!(structure(&imp.project, n, TrackKind::Audio), structure(&p, s, TrackKind::Audio), "{rate:?}: {rep}");
assert_eq!(links(&imp.project, n), links(&p, s));
let q = imp.project.sequence(n).unwrap();
assert_eq!(q.settings.frame_rate, rate);
assert_eq!(q.settings.drop_frame, df);
assert_eq!(q.start_timecode, rate.timecode_base() * 3600);
assert_eq!((q.settings.width, q.settings.height), (1920, 1080));
assert_eq!(imp.project.item(n).unwrap().name, "Edit 1");
// markers
assert_eq!(q.markers.len(), 1);
assert_eq!((q.markers[0].start, q.markers[0].duration), (rate.tick_of(50), rate.tick_of(10)));
assert_eq!((q.markers[0].name.as_str(), q.markers[0].comment.as_str(), q.markers[0].color), ("Fix this", "colour", Label::Mango));
// gains
assert!(close(level(&imp.project, n, 0, 1).value.as_f64().unwrap(), -6.0));
let k = level(&imp.project, n, 1, 0);
let want = level(&p, s, 1, 0);
assert_eq!(k.keyframes.len(), 3);
for (a, b) in k.keyframes.iter().zip(&want.keyframes) {
// control points are stored in samples: keyframes not on a sample move to the nearest one
assert!((a.time - b.time).abs() <= Tick(filmcraft_time::TICKS_PER_SECOND / 48_000 / 2), "{rate:?}: {a:?} {b:?}");
assert!(close(a.value.as_f64().unwrap(), b.value.as_f64().unwrap()), "{a:?} {b:?}");
}
// media: paths, timecode, markers
let a = imp
.project
.items
.values()
.find(|i| matches!(&i.kind, ItemKind::Media(m) if matches!(&m.media, MediaRef::File{path} if path == "/media/A001.mov")))
.unwrap();
let ma = a.as_media().unwrap();
assert_eq!(ma.info.start_timecode, Some(rate.timecode_base() * 3600 + 12));
assert!(ma.info.video.is_some() && ma.info.audio.is_some());
assert_eq!(ma.markers.len(), 1);
assert_eq!((ma.markers[0].name.as_str(), ma.markers[0].comment.as_str()), ("slate", "take 3"));
let mus = imp.project.items.values().find(|i| i.name == "music.wav").unwrap();
assert!(mus.as_media().unwrap().info.video.is_none());
// the same document again: byte-identical (reproducible)
assert_eq!(aaf::export(&p, s, &AafOptions::default()).unwrap().0, aaf::export(&p, s, &AafOptions::default()).unwrap().0);
}
}
#[test]
fn aaf_small_sectors_and_generic_api() {
let (p, s) = sample(FrameRate::FPS_25, false);
let (b512, _) = aaf::export(&p, s, &AafOptions { small_sectors: true, ..Default::default() }).unwrap();
assert_eq!(u16::from_le_bytes([b512[30], b512[31]]), 9);
let (imp, _) = filmcraft_interchange::import(&b512, Format::Aaf, None).unwrap();
let n = only_seq(&imp);
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video));
let (bytes, _) = filmcraft_interchange::export(&p, s, Format::Aaf, &Default::default()).unwrap();
assert_eq!(u16::from_le_bytes([bytes[30], bytes[31]]), 12);
}
#[test]
fn aaf_breakout_to_mono() {
let (p, s) = sample(FrameRate::FPS_25, false);
let opts = AafOptions { media: MediaOptions { breakout_to_mono: true, ..Default::default() }, ..Default::default() };
let (imp, _, _) = aaf_rt(&p, s, &opts);
let n = only_seq(&imp);
let q = imp.project.sequence(n).unwrap();
assert_eq!(q.audio_tracks.len(), 8, "four stereo tracks → eight mono tracks");
for (i, t) in q.audio_tracks.iter().enumerate() {
assert_eq!(t.channels, AudioChannels::Mono);
let ch = (i % 2) as u16;
assert!(t.items.iter().all(|c| c.source_channels == vec![ch]), "track {i}");
}
// left and right of the same clip use the same media item
assert_eq!(q.audio_tracks[0].items[0].item, q.audio_tracks[1].items[0].item);
let orig = structure(&p, s, TrackKind::Audio);
let got = structure(&imp.project, n, TrackKind::Audio);
assert_eq!(got[0], orig[0]);
assert_eq!(got[1], orig[0]);
assert_eq!(got[2], orig[1]);
}
fn pcm_ramp(frames: usize, channels: usize, seed: i32) -> Vec<u8> {
let mut v = Vec::new();
for i in 0..frames {
for c in 0..channels {
let s = ((i as i32 * 37 + c as i32 * 1000 + seed) % 30000) as i16;
v.extend_from_slice(&s.to_le_bytes());
}
}
v
}
#[test]
fn aaf_embedded_trimmed_audio() {
let (p, s) = sample(FrameRate::FPS_25, false);
let handles = FrameRate::FPS_25.tick_of(5);
let needs = audio_needs(&p, s, &NeedOptions { handles, per_clip: false, whole_media: false });
assert_eq!(needs.len(), 3);
let a = needs.iter().find(|n| n.path.as_deref() == Some("/media/A001.mov")).unwrap();
// A001 is used at source 20..120 and 300..360 (+ 5 frame handles, plus the crossfade's 5 frames)
assert_eq!(a.start, FrameRate::FPS_25.tick_of(15));
assert_eq!(a.end, FrameRate::FPS_25.tick_of(365));
let mut essence = Vec::new();
for (k, n) in needs.iter().enumerate() {
let frames = ((n.end - n.start).0 / (filmcraft_time::TICKS_PER_SECOND / 48_000)) as u64;
essence.push(AudioEssence {
key: n.key,
channel: None,
start: n.start,
frames,
sample_rate: 48_000,
bits: 16,
channels: 2,
data: EssenceData::Embedded(pcm_ramp(frames as usize, 2, k as i32)),
effects_rendered: false,
});
}
let opts = AafOptions { media: MediaOptions { essence: essence.clone(), ..Default::default() }, ..Default::default() };
let (imp, extracted, rep) = aaf_rt(&p, s, &opts);
assert_eq!(extracted.len(), 3, "{rep}");
let n = only_seq(&imp);
for e in &extracted {
let (pcm, ch, sr, bits) = filmcraft_interchange::wav::parse_wav(&e.wav).unwrap();
assert_eq!((ch, sr, bits), (2, 48_000, 16));
assert!(essence.iter().any(|x| matches!(&x.data, EssenceData::Embedded(d) if *d == pcm)));
assert!(e.path.starts_with("/proj/rt Media/"), "{}", e.path);
let it = imp.project.item(e.item).unwrap().as_media().unwrap();
assert!(matches!(&it.media, MediaRef::File { path } if *path == e.path));
}
// the audio clips now point into the trimmed essence: source times shift by the trim start
let q = imp.project.sequence(n).unwrap();
let orig = p.sequence(s).unwrap();
for (t, ot) in q.audio_tracks.iter().zip(&orig.audio_tracks) {
for (c, oc) in t.items.iter().zip(&ot.items) {
let need = needs.iter().find(|x| x.item == oc.item).unwrap();
assert_eq!(c.source_in, oc.source_in - need.start);
assert_eq!((c.start, c.duration), (oc.start, oc.duration));
}
}
// video still links to the original movies
assert_eq!(structure(&imp.project, n, TrackKind::Video), structure(&p, s, TrackKind::Video));
}
#[test]
fn aaf_linked_consolidated_audio_and_mixdown() {
let (p, s) = sample(FrameRate::FPS_25, false);
let needs = audio_needs(&p, s, &NeedOptions { per_clip: true, ..Default::default() });
assert_eq!(needs.len(), 4, "one range per audio clip");
let essence: Vec<AudioEssence> = needs
.iter()
.flat_map(|n| {
(0..2).map(move |c| AudioEssence {
key: n.key,
channel: Some(c),
start: n.start,
frames: 4800,
sample_rate: 48_000,
bits: 24,
channels: 1,
data: EssenceData::File { path: format!("/out/{:?}_{c}.wav", n.key).replace(['(', ')'], "") },
effects_rendered: true,
})
})
.collect();
let mix = MixdownVideo { path: "/out/mixdown.mxf".into(), start: Tick::ZERO, duration: FrameRate::FPS_25.tick_of(280), width: 1920, height: 1080 };
let opts = AafOptions { media: MediaOptions { essence, breakout_to_mono: true, mixdown_video: Some(mix), ..Default::default() }, ..Default::default() };
let (imp, extracted, rep) = aaf_rt(&p, s, &opts);
assert!(extracted.is_empty());
let n = only_seq(&imp);
let q = imp.project.sequence(n).unwrap();
assert_eq!(q.video_tracks.len(), 1);
assert_eq!(q.video_tracks[0].items.len(), 1);
assert_eq!(q.video_tracks[0].items[0].duration, FrameRate::FPS_25.tick_of(280));
assert_eq!(structure(&imp.project, n, TrackKind::Video)[0].clips[0].media, "/out/mixdown.mxf");
assert_eq!(q.audio_tracks.len(), 8);
let paths: Vec<String> = q.audio_tracks.iter().flat_map(|t| t.items.iter().map(|c| media_key(&imp.project, c.item))).collect();
assert!(paths.iter().all(|p| p.starts_with("/out/Clip")), "{paths:?} {rep}");
// rendered clip audio carries no gain any more
for t in &q.audio_tracks {
for c in &t.items {
assert!(c.effect("volume").and_then(|e| e.param("level")).is_none_or(|p| !p.is_animated() && p.value.as_f64() == Some(0.0)));
}
}
}
#[test]
fn omf_round_trip_audio_only() {
for rate in [FrameRate::FPS_25, FrameRate::FPS_29_97] {
let (p, s) = sample(rate, rate.is_ntsc());
let needs = audio_needs(&p, s, &NeedOptions { handles: rate.tick_of(10), ..Default::default() });
let essence: Vec<AudioEssence> = needs
.iter()
.enumerate()
.map(|(k, n)| {
let frames = ((n.end - n.start).0 / (filmcraft_time::TICKS_PER_SECOND / 48_000)) as u64;
AudioEssence {
key: n.key,
channel: None,
start: n.start,
frames,
sample_rate: 48_000,
bits: 16,
channels: 2,
data: EssenceData::Embedded(pcm_ramp(frames as usize, 2, k as i32 * 7)),
effects_rendered: false,
}
})
.collect();
let opts = OmfOptions { media: MediaOptions { essence: essence.clone(), ..Default::default() }, ..Default::default() };
let (bytes, rep) = omf::export(&p, s, &opts).unwrap();
assert!(rep.mentions("markers"), "{rep}");
assert!(omf::sniff(&bytes));
assert_eq!(detect(&bytes, Some("omf")), Some(Format::Omf));
let (imp, extracted, rep) = omf::import(&bytes, &ImportOptions { base_dir: Some("/x".into()), name: Some("o".into()), ..Default::default() }).unwrap();
assert_eq!(extracted.len(), 3, "{rep}");
let n = only_seq(&imp);
let q = imp.project.sequence(n).unwrap();
assert!(q.video_tracks.iter().all(|t| t.items.is_empty()), "OMF exports are audio only");
assert_eq!(q.start_timecode, rate.timecode_base() * 3600);
assert_eq!(q.settings.frame_rate, rate);
let orig = p.sequence(s).unwrap();
assert_eq!(q.audio_tracks.len(), orig.audio_tracks.len());
for (t, ot) in q.audio_tracks.iter().zip(&orig.audio_tracks) {
assert_eq!(t.items.len(), ot.items.len());
for (c, oc) in t.items.iter().zip(&ot.items) {
let need = needs.iter().find(|x| x.item == oc.item).unwrap();
assert_eq!((c.start, c.duration, c.source_in), (oc.start, oc.duration, oc.source_in - need.start));
}
assert_eq!(t.transitions.len(), ot.transitions.len());
for (x, ox) in t.transitions.iter().zip(&ot.transitions) {
assert_eq!((x.start, x.duration, &x.effect.effect, x.align), (ox.start, ox.duration, &ox.effect.effect, ox.align));
}
}
assert!(close(level(&imp.project, n, 0, 1).value.as_f64().unwrap(), -6.0));
assert_eq!(level(&imp.project, n, 1, 0).keyframes.len(), 3);
for e in &extracted {
let (pcm, ..) = filmcraft_interchange::wav::parse_wav(&e.wav).unwrap();
assert!(essence.iter().any(|x| matches!(&x.data, EssenceData::Embedded(d) if *d == pcm)));
}
// the media keeps its source timecode
let a = imp.project.items.values().find(|i| i.name == "A001.mov").expect("master mob name");
assert_eq!(a.as_media().unwrap().info.start_timecode, Some(rate.timecode_base() * 3600 + 12));
}
}
#[test]
fn omf_separate_files_and_breakout() {
let (p, s) = sample(FrameRate::FPS_25, false);
let needs = audio_needs(&p, s, &NeedOptions::default());
let essence: Vec<AudioEssence> = needs
.iter()
.flat_map(|n| {
(0..2u32).map(move |c| AudioEssence {
key: n.key,
channel: Some(c),
start: n.start,
frames: 1000,
sample_rate: 48_000,
bits: 24,
channels: 1,
data: EssenceData::File { path: format!("/omf/media/{}_{c}.aif", n.item.0) },
effects_rendered: false,
})
})
.collect();
let opts = OmfOptions { media: MediaOptions { essence, breakout_to_mono: true, ..Default::default() }, ..Default::default() };
let (bytes, _) = omf::export(&p, s, &opts).unwrap();
let (imp, extracted, _) = omf::import(&bytes, &ImportOptions::default()).unwrap();
assert!(extracted.is_empty());
let n = only_seq(&imp);
let q = imp.project.sequence(n).unwrap();
assert_eq!(q.audio_tracks.len(), 8);
for t in &q.audio_tracks {
assert_eq!(t.channels, AudioChannels::Mono);
for c in &t.items {
let k = media_key(&imp.project, c.item);
assert!(k.starts_with("/omf/media/") && k.ends_with(".aif"), "{k}");
let m = imp.project.item(c.item).unwrap().as_media().unwrap();
assert_eq!(m.info.audio.as_ref().map(|a| (a.channels, a.sample_rate)), Some((1, 48_000)));
}
}
}
#[test]
fn empty_and_damaged_documents() {
let mut p = Project::new("e");
let s = sequence(&mut p, "Empty", FrameRate::FPS_24, false);
for small in [false, true] {
let (bytes, _) = aaf::export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
let (imp, _, _) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(imp.sequences.len(), 1);
}
let (bytes, _) = omf::export(&p, s, &OmfOptions::default()).unwrap();
assert_eq!(omf::import(&bytes, &ImportOptions::default()).unwrap().0.sequences.len(), 1);
assert!(aaf::import(b"not an aaf", &ImportOptions::default()).is_err());
assert!(omf::import(b"not an omf", &ImportOptions::default()).is_err());
assert!(!aaf::sniff(b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1 but short"));
// truncation and corruption: errors, never panics
let (p, s) = sample(FrameRate::FPS_25, false);
let (aaf_bytes, _) = aaf::export(&p, s, &AafOptions { small_sectors: true, ..Default::default() }).unwrap();
let needs = audio_needs(&p, s, &NeedOptions::default());
let essence = needs
.iter()
.map(|n| AudioEssence {
key: n.key,
channel: None,
start: n.start,
frames: 100,
sample_rate: 48_000,
bits: 16,
channels: 2,
data: EssenceData::Embedded(pcm_ramp(100, 2, 1)),
effects_rendered: false,
})
.collect();
let (omf_bytes, _) = omf::export(&p, s, &OmfOptions { media: MediaOptions { essence, ..Default::default() }, ..Default::default() }).unwrap();
for (bytes, is_aaf) in [(&aaf_bytes, true), (&omf_bytes, false)] {
for cut in (0..bytes.len()).step_by(97) {
let part = &bytes[..cut];
let _ = if is_aaf { aaf::import(part, &ImportOptions::default()).map(|_| ()) } else { omf::import(part, &ImportOptions::default()).map(|_| ()) };
}
let mut seed = 0xA5A5_1234_5678_9ABCu64;
for _ in 0..400 {
let mut g = bytes.clone();
for _ in 0..8 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let at = (seed as usize) % g.len();
g[at] = (seed >> 40) as u8;
}
let _ = if is_aaf { aaf::import(&g, &ImportOptions::default()).map(|_| ()) } else { omf::import(&g, &ImportOptions::default()).map(|_| ()) };
}
}
}
#[test]
fn unsupported_clips_become_gaps_with_a_report() {
let mut p = Project::new("g");
let s = sequence(&mut p, "S", FrameRate::FPS_25, false);
let g = generator(&mut p, "Bars", filmcraft_media::Generator::BarsAndTone);
let a = media(&mut p, "/m/a.mov", true, true, FrameRate::FPS_25);
clip(&mut p, s, TrackKind::Video, 0, g, 0, 25, 0);
clip(&mut p, s, TrackKind::Video, 0, a, 25, 25, 0);
let (bytes, rep) = aaf::export(&p, s, &AafOptions::default()).unwrap();
assert!(rep.mentions("synthetic"), "{rep}");
let (imp, _, _) = aaf::import(&bytes, &ImportOptions::default()).unwrap();
let n = only_seq(&imp);
let v = &imp.project.sequence(n).unwrap().video_tracks[0];
assert_eq!(v.items.len(), 1);
assert_eq!(v.items[0].start, FrameRate::FPS_25.tick_of(25));
}

View File

@ -1,6 +1,6 @@
[package]
name = "filmcraft-mxf"
description = "Clean-room MXF (SMPTE ST 377-1) demuxer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence"
description = "Clean-room MXF (SMPTE ST 377-1) demuxer and writer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@ -1,6 +1,6 @@
# filmcraft-mxf
A clean-room MXF demuxer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for
A clean-room MXF demuxer and writer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for
`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libMXF, bmx, MXFLib) was consulted; FFmpeg is
used only as an external fixture generator and test oracle.
@ -22,7 +22,8 @@ Implemented from the SMPTE documents (editions used):
| SMPTE ST 386 | 2004 | D-10 (IMX) mapping: container label, sound element type |
| SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping |
| SMPTE RDD 44 | 2017 | Apple ProRes mapping |
| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns |
| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns; property labels of the primer pack (writer) |
| SMPTE ST 330 | 2011 | basic UMIDs of the written packages |
## Reading a file
@ -72,6 +73,57 @@ Not supported: OP1b/OP2x+ edit lists beyond the first source clip, external esse
material packages whose other tracks live in other files are opened track by track), partial or
encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored).
## Writing a file
```rust
use filmcraft_mxf::*;
let ids = PackageIds::from_seed("unique seed (path + time)", "Clip name"); // material + file UMIDs
let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids);
cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 1920, 1080));
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 24 }];
cfg.timecode = Some(StartTimecode { frames: 90_000, rate: Rational::new(25, 1), drop_frame: false });
let mut w = MxfWriter::new(std::io::BufWriter::new(std::fs::File::create("out.mxf")?), cfg)?;
w.push_picture(&frame_bytes, FrameInfo::intra())?; // stored order; AVC: key / P / B + display position
w.push_sound(0, &interleaved_le_pcm)?; // any chunking: content packages are cut per edit unit
w.finish()?;
```
- **Streaming.** Any `Write + Seek` sink. `new` writes the header partition (open, incomplete,
durations unknown) and a body partition; essence follows as it is pushed; `finish` writes the
footer partition (closed, complete header metadata with the final durations and the index table
segments), the random index pack, then rewrites the header partition pack and header metadata in
place (the metadata's size does not depend on the durations; a reserved fill item absorbs any
difference) and the body partition pack's footer offset. Only the index entries (11 bytes per
edit unit, plus 4 per sound slice) are kept in memory.
- **OP1a** (ST 378): one content package per edit unit, frame wrapping (ST 379-1): the picture
element then one element per sound track. Sound is buffered and cut into edit units exactly
(`floor((n+1)·rate·den/num) − floor(n·rate·den/num)` samples: 1601/1602 at 29.97). Pictures
without their sound yet wait; `finish` pads the last units' sound with silence. A multiple
descriptor with one sub-descriptor per track (linked track ids), the "multiple wrappings"
container label.
- **OP-Atom** (ST 390, Avid style): exactly one essence track per file, clip-wrapped (one element
with a 9-byte BER length patched by `finish`). Several atom files of one clip can share the
material package UMID with distinct track ids (`WriterConfig::first_track_id`). Sound files may
use the sample rate as the edit rate (a constant-bytes index) or the frame rate (VBR entries).
- **Index tables** (ST 377-1 §11): VBR entries per edit unit — stream offset (from the first
essence byte; clip-wrapped: from the element value), key-frame offset, flags (`0x80` random
access, `0xC0` AVC IDR with its parameter sets, `0x22` P, `0x33` B) and temporal offsets from the
display positions (`TO[d] = stored(d) − d`); a delta entry and a slice per sound element. Split
into segments of at most ~60 KB (the entry array is a 2-byte-length local set item).
- **Metadata.** Preface, identification (company / product / version / modification date),
content storage, essence container data (BodySID 1, IndexSID 2), material and file packages
(`PackageIds`: UMIDs and names) with a timecode track (start, rounded base, drop frame) and one
timeline track per essence (source clip → file package track; track numbers = element keys),
CDCI picture descriptors (size, aspect ratio, depth, subsampling, black / white / range, BT.709 /
BT.2020 PQ / HLG colour labels, coding label) and wave audio descriptors.
- **Mappings.** VC-3 (ST 2019-4: coding label `…71 cc` from the compression id, container `02 11`,
element types 0x0C / 0x0D), ProRes (RDD 44: coding label profile byte, container `02 1C`),
AVC byte stream (ST 381-3: container `02 10 60`, coding label from `profile_idc`; Annex B access
units with in-band SPS / PPS — the AVC sub-descriptor is not written), Broadcast Wave PCM
(ST 382: container `02 06 01/02`, element types 0x01 / 0x02).
- `write_opatom_pcm(&OpAtomPcm, &[i32])` builds a complete in-memory OP-Atom PCM file (AAF media
consolidation); `encode_pcm` interleaves planar f32 as little-endian PCM.
## Tests
- `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM,
@ -82,3 +134,13 @@ encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored)
timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4),
OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file;
truncated and corrupted files. `cargo xtask fixtures codecs` pre-generates them.
- `src/write/tests.rs`: writer output read back by `open`: OP1a with two sound tracks pushed in
uneven chunks (durations, codec label, aspect, PCM exact, index slices, closed partitions),
29.97 sound per edit unit (1601/1602), a long-GOP AVC index (presentation order, key and B
flags), OP-Atom picture (clip wrapping) and PCM files at both edit rates, multi-segment index
of 12 000 units, UMIDs; truncation and random mutation of written files never panic.
- `crates/export/src/mxf_tests.rs` (ffprobe / ffmpeg as external oracles): OP1a exports with
DNxHR, ProRes and H.264 — ffprobe reports the OP1a label, codec, 24 frames, frame rate, duration
and `01:00:00:00` timecode; ffmpeg decodes the MXF picture exactly as it decodes the same
essence exported as MOV, and its PCM equals our demuxer's sample for sample; OP-Atom exports —
picture file and one mono file per channel, each channel equal to the WAV export's.

View File

@ -12,6 +12,9 @@
//! presentation position from the index temporal offsets, random-access flag) and PCM chunk
//! tables for sound, plus the start timecode of the material package.
//!
//! [`MxfWriter`] ([`write`]) writes OP1a and OP-Atom files (VC-3, ProRes, AVC byte stream, PCM)
//! with index tables and start timecode, streaming to any `Write + Seek` sink.
//!
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
mod essence;
@ -20,12 +23,17 @@ mod index;
mod klv;
mod meta;
mod source;
pub mod write;
pub use essence::{Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm};
pub use file::{Chunk, EssenceTrack, MxfFile, OperationalPattern, Partition, PartitionKind, Sample, Timecode, TrackKind, Wrapping, open};
pub use index::{IndexEntry, IndexSegment};
pub use klv::{Rational, Ul};
pub use source::ByteSource;
pub use write::{
CodedKind, ColorSpace, FrameInfo, MxfWriter, OpAtomPcm, PackageIds, Pattern, PictureCoding, PictureDesc, SoundDesc, StartTimecode, Timestamp, Umid,
WriterConfig, encode_pcm, write_opatom_pcm,
};
use std::fmt;
use std::io;

View File

@ -0,0 +1,144 @@
//! Labels and keys used by the writer (SMPTE RP 224 / RP 210 registers, ST 377-1, ST 379-2 and the
//! essence mapping documents).
/// Header metadata set key: 06 0E 2B 34 02 53 01 01 0D 01 01 01 01 01 tt 00.
pub fn set_key(kind: u8) -> [u8; 16] {
[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, kind, 0x00]
}
/// Partition pack key (kind 2 header, 3 body, 4 footer; status 1 open incomplete … 4 closed complete).
pub fn partition_key(kind: u8, status: u8) -> [u8; 16] {
[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, kind, status, 0x00]
}
pub const PRIMER_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x05, 0x01, 0x00];
pub const INDEX_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x10, 0x01, 0x00];
pub const RIP_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x11, 0x01, 0x00];
/// OP1a (ST 378); byte 14 qualifiers: bit 3 multi-track, bit 0 always set.
pub fn op1a(multi_track: bool) -> [u8; 16] {
[0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x01, if multi_track { 0x09 } else { 0x01 }, 0x00]
}
/// OP-Atom (ST 390).
pub const OP_ATOM: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02, 0x0D, 0x01, 0x02, 0x01, 0x10, 0x00, 0x00, 0x00];
/// Data definitions (RP 224).
pub const DEF_PICTURE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00];
pub const DEF_SOUND: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00];
pub const DEF_TIMECODE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00];
/// Generic container: multiple wrappings (ST 379-2).
pub const EC_MULTIPLE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x03, 0x0D, 0x01, 0x03, 0x01, 0x02, 0x7F, 0x01, 0x00];
/// Essence container label of a generic-container mapping: 06 0E 2B 34 04 01 01 vv 0D 01 03 01 02 mm ww xx.
pub fn essence_container(version: u8, mapping: u8, b14: u8, b15: u8) -> [u8; 16] {
[0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, version, 0x0D, 0x01, 0x03, 0x01, 0x02, mapping, b14, b15]
}
/// Generic container element key: item type (0x15 picture, 0x16 sound), element count, element type, number.
pub fn element_key(item: u8, count: u8, kind: u8, number: u8) -> [u8; 16] {
[0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, item, count, kind, number]
}
pub const KLV_FILL: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x10, 0x01, 0x00, 0x00, 0x00];
/// Colour labels (RP 224): transfer characteristics, coding equations, colour primaries.
pub const TC_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00];
pub const TC_PQ: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0A, 0x00, 0x00];
pub const TC_HLG: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0B, 0x00, 0x00];
pub const CE_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x02, 0x02, 0x00, 0x00];
pub const CE_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x02, 0x06, 0x00, 0x00];
pub const CP_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x06, 0x04, 0x01, 0x01, 0x01, 0x03, 0x03, 0x00, 0x00];
pub const CP_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x03, 0x04, 0x00, 0x00];
/// Static local tags written, with their property labels (RP 210), for the primer pack.
pub const PRIMER: &[(u16, [u8; 16])] = &[
(0x3C0A, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x02])),
(0x3B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x04])),
(0x3B05, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x05])),
(0x3B07, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x04])),
(0x3B06, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x04])),
(0x3B03, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x01])),
(0x3B09, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x03])),
(0x3B0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x01])),
(0x3B0B, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x02])),
(0x3C09, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x01])),
(0x3C01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x02, 0x01])),
(0x3C02, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x03, 0x01])),
(0x3C04, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x05, 0x01])),
(0x3C05, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x07])),
(0x3C06, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x03])),
(0x1901, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x01])),
(0x1902, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x02])),
(0x2701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x06, 0x01])),
(0x3F06, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x05])),
(0x3F07, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x04])),
(0x4401, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x10])),
(0x4402, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x03, 0x02, 0x01])),
(0x4405, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x01, 0x03])),
(0x4404, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x05])),
(0x4403, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x05])),
(0x4701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x03])),
(0x4801, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x01])),
(0x4804, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x04, 0x01, 0x03])),
(0x4802, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x02, 0x01])),
(0x4803, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x04])),
(0x4B01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x30, 0x04, 0x05])),
(0x4B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x03])),
(0x0201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x07, 0x01])),
(0x0202, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x02, 0x01, 0x01, 0x03])),
(0x1001, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x09])),
(0x1201, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x04])),
(0x1101, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x01])),
(0x1102, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x02])),
(0x1502, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x04, 0x01, 0x01, 0x02, 0x06])),
(0x1501, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x05])),
(0x1503, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x04, 0x01, 0x01, 0x05])),
(0x3F01, ul(&[0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01, 0x04, 0x06, 0x0B])),
(0x3001, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01])),
(0x3002, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x02])),
(0x3004, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x01, 0x02])),
(0x3006, ul(&[0x01, 0x01, 0x01, 0x05, 0x06, 0x01, 0x01, 0x03, 0x05])),
(0x320C, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x01, 0x04])),
(0x3203, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x02])),
(0x3202, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x01])),
(0x3205, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x08])),
(0x3204, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x07])),
(0x3209, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0C])),
(0x3208, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0B])),
(0x320E, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01])),
(0x320D, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x03, 0x02, 0x05])),
(0x3201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x06, 0x01])),
(0x3210, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02])),
(0x321A, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x03, 0x01])),
(0x3219, ul(&[0x01, 0x01, 0x01, 0x09, 0x04, 0x01, 0x02, 0x01, 0x01, 0x06, 0x01])),
(0x3301, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x0A])),
(0x3302, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x05])),
(0x3308, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x01, 0x10])),
(0x3303, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x06])),
(0x3304, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x03])),
(0x3305, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x04])),
(0x3306, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x05])),
(0x3D03, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x01, 0x01, 0x01])),
(0x3D02, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x01, 0x04])),
(0x3D07, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x01, 0x01, 0x04])),
(0x3D01, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x03, 0x04])),
(0x3D0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x02, 0x01])),
(0x3D09, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x03, 0x05])),
];
/// A property label: 06 0E 2B 34 followed by `rest`, zero padded.
const fn ul(rest: &[u8]) -> [u8; 16] {
let mut k = [0u8; 16];
k[0] = 0x06;
k[1] = 0x0E;
k[2] = 0x2B;
k[3] = 0x34;
let mut i = 0;
while i < rest.len() && i < 12 {
k[4 + i] = rest[i];
i += 1;
}
k
}

1190
crates/mxf/src/write/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,318 @@
//! Writer tests: files read back with our own demuxer; truncation and mutation never panic.
use std::io::Cursor;
use super::*;
use crate::{Codec, OperationalPattern, PartitionKind, TrackKind, Wrapping, open};
fn prores_like(i: usize) -> Vec<u8> {
let mut v = vec![0, 0, 0, 40];
v.extend_from_slice(b"icpf");
v.extend(std::iter::repeat_n(i as u8, 32 + i * 3));
v
}
fn vc3_like(i: usize) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x02, 0x80, 0x01];
v.extend(std::iter::repeat_n(i as u8 ^ 0x5A, 500 + 7 * i));
v
}
/// 16-bit stereo PCM: frame k = (k, -k).
fn pcm16(start: usize, n: usize) -> Vec<u8> {
let mut v = Vec::new();
for k in start..start + n {
let s = (k % 30000) as i16;
v.extend_from_slice(&s.to_le_bytes());
v.extend_from_slice(&(-s).to_le_bytes());
}
v
}
/// 24-bit mono PCM: frame k = 1000 * k (wrapping inside the 24-bit range).
fn pcm24(start: usize, n: usize) -> Vec<u8> {
let mut v = Vec::new();
for k in start..start + n {
let s = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000;
v.extend_from_slice(&s.to_le_bytes()[..3]);
}
v
}
fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig {
let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("test", "Test Clip"));
cfg.picture = Some(PictureDesc::new(coding, 64, 36));
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }, SoundDesc { sample_rate: 48_000, channels: 1, bits: 24 }];
cfg.timecode = Some(StartTimecode { frames: 90_000, rate, drop_frame: false });
cfg
}
/// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks.
fn build_op1a() -> Vec<u8> {
let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 });
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
let total = 10 * 1920;
let (mut a, mut b) = (0, 0);
for i in 0..10 {
// sound before and after the picture, in chunks unrelated to the edit units
let n = 1500.min(total - a);
w.push_sound(0, &pcm16(a, n)).unwrap();
a += n;
w.push_picture(&prores_like(i), FrameInfo::intra()).unwrap();
let m = 2500.min(total - b);
w.push_sound(1, &pcm24(b, m)).unwrap();
b += m;
}
w.push_sound(0, &pcm16(a, total - a)).unwrap();
assert_eq!(w.edit_units(), 10);
w.finish().unwrap().into_inner()
}
#[test]
fn op1a_reads_back() {
let f = build_op1a();
let m = open(&f).unwrap();
assert!(m.warnings.is_empty(), "{:?}", m.warnings);
assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 });
assert_eq!(m.material_package_name.as_deref(), Some("Test Clip"));
assert_eq!(m.product_name.as_deref(), Some("FilmCraft"));
let tc = m.timecode.unwrap();
assert_eq!(tc.format(), "01:00:00:00");
// header rewritten closed and complete; body and footer partitions; footer metadata used
let kinds: Vec<(PartitionKind, u8)> = m.partitions.iter().map(|p| (p.kind, p.status)).collect();
assert_eq!(kinds, vec![(PartitionKind::Header, 4), (PartitionKind::Body, 4), (PartitionKind::Footer, 4)]);
assert!(m.partitions.iter().all(|p| p.footer_partition == m.partitions[2].offset));
assert_eq!(m.tracks.len(), 3);
let v = m.track_of_kind(TrackKind::Picture).unwrap();
let t = &m.tracks[v];
assert_eq!(t.codec, Codec::ProRes { profile: Some(3) });
assert_eq!(t.duration, Some(10));
assert_eq!(t.edit_rate, Rational::new(25, 1));
assert_eq!(t.wrapping, Wrapping::Frame);
assert!(t.indexed || t.codec.intra_only());
let p = t.picture.as_ref().unwrap();
assert_eq!((p.frame_width(), p.frame_height(), p.component_depth), (64, 36, 10));
assert_eq!(p.aspect_ratio, Rational::new(16, 9));
for i in 0..10 {
assert_eq!(m.read_sample(&f, v, i).unwrap(), prores_like(i));
}
let sounds: Vec<usize> = (0..m.tracks.len()).filter(|&i| m.tracks[i].kind == TrackKind::Sound).collect();
assert_eq!(sounds.len(), 2);
let s0 = &m.tracks[sounds[0]];
assert_eq!(s0.codec, Codec::Pcm);
assert_eq!(s0.stored_sample_frames(), 10 * 1920);
assert_eq!(s0.chunks.len(), 10);
let pcm = m.read_pcm(&f, sounds[0], 1900, 100).unwrap();
for k in 0..100 {
assert_eq!(pcm[0][k], (1900 + k) as f32 / 32768.0);
assert_eq!(pcm[1][k], -((1900 + k) as f32) / 32768.0);
}
let s1 = &m.tracks[sounds[1]];
assert_eq!(s1.sound.as_ref().unwrap().bits, 24);
let pcm = m.read_pcm(&f, sounds[1], 0, 10 * 1920).unwrap();
for k in (0..10 * 1920).step_by(97) {
let want = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000;
assert_eq!(pcm[0][k], want as f32 / 8_388_608.0, "sample {k}");
}
// index: one entry per content package, two sound slices
let entries: usize = m.index_segments.iter().map(|s| s.entries.len()).sum();
assert_eq!(entries, 10);
assert_eq!(m.index_segments[0].slice_count, 2);
assert_eq!(m.index_segments[0].delta_entries.len(), 3);
}
#[test]
fn ntsc_sound_follows_the_edit_unit_pattern() {
let rate = Rational::new(30000, 1001);
let mut cfg = op1a_config(rate, PictureCoding::Vc3 { cid: 1274 });
cfg.sound.truncate(1);
cfg.timecode = Some(StartTimecode { frames: 107_892, rate, drop_frame: true });
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
let n = 12;
let total = (n as u64 * 48_000 * 1001 / 30_000) as usize;
w.push_sound(0, &pcm16(0, total)).unwrap();
for i in 0..n {
w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap();
}
let f = w.finish().unwrap().into_inner();
let m = open(&f).unwrap();
assert_eq!(m.timecode.unwrap().format(), "01:00:00;00");
let a = m.track_of_kind(TrackKind::Sound).unwrap();
let sizes: Vec<u64> = m.tracks[a].chunks.iter().map(|c| c.samples).collect();
assert_eq!(sizes[..5], [1601, 1602, 1601, 1602, 1602]);
assert_eq!(m.tracks[a].stored_sample_frames(), total as u64);
let pcm = m.read_pcm(&f, a, 0, total).unwrap();
assert!((0..total).all(|k| pcm[0][k] == (k % 30000) as f32 / 32768.0));
let v = m.track_of_kind(TrackKind::Picture).unwrap();
assert_eq!(m.tracks[v].codec, Codec::Vc3);
}
#[test]
fn long_gop_index_gives_presentation_order_and_key_frames() {
let rate = Rational::new(25, 1);
let mut cfg = op1a_config(rate, PictureCoding::Avc { profile_idc: 100, intra: false });
cfg.sound.clear();
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
// stored I0 P3 B1 B2 P6 B4 B5 I7(IDR)
let order = [(0, CodedKind::Intra, true), (3, CodedKind::Predicted, false), (1, CodedKind::Bidirectional, false), (2, CodedKind::Bidirectional, false)];
let more = [(6, CodedKind::Predicted, false), (4, CodedKind::Bidirectional, false), (5, CodedKind::Bidirectional, false), (7, CodedKind::Intra, true)];
for (i, (d, kind, key)) in order.iter().chain(more.iter()).enumerate() {
let au = vec![0, 0, 0, 1, if *key { 0x65 } else { 0x41 }, i as u8];
w.push_picture(&au, FrameInfo { key: *key, kind: *kind, display: Some(*d) }).unwrap();
}
let f = w.finish().unwrap().into_inner();
let m = open(&f).unwrap();
let t = &m.tracks[0];
assert_eq!(t.codec, Codec::Avc { intra: false });
assert!(t.temporal_offsets && t.indexed);
let pts: Vec<i64> = t.samples.iter().map(|s| s.pts).collect();
assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5, 7]);
let keys: Vec<bool> = t.samples.iter().map(|s| s.key).collect();
assert_eq!(keys, vec![true, false, false, false, false, false, false, true]);
let b: Vec<bool> = t.samples.iter().map(|s| s.b_picture).collect();
assert_eq!(b, vec![false, false, true, true, false, true, true, false]);
assert_eq!(t.sample_at(4), Some(5));
}
#[test]
fn op_atom_picture_is_clip_wrapped() {
let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(24, 1), PackageIds::from_seed("atom-v", "Atom V"));
cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 64, 36));
cfg.timecode = Some(StartTimecode { frames: 86_400, rate: Rational::new(24, 1), drop_frame: false });
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
for i in 0..9 {
w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap();
}
assert!(w.push_sound(0, &[0, 0]).is_err());
let f = w.finish().unwrap().into_inner();
let m = open(&f).unwrap();
assert!(m.warnings.is_empty(), "{:?}", m.warnings);
assert_eq!(m.operational_pattern, OperationalPattern::Atom);
assert_eq!(m.timecode.unwrap().format(), "01:00:00:00");
let t = &m.tracks[0];
assert_eq!(t.wrapping, Wrapping::Clip);
assert_eq!(t.codec, Codec::Vc3);
assert_eq!(t.samples.len(), 9);
for i in 0..9 {
assert_eq!(m.read_sample(&f, 0, i).unwrap(), vc3_like(i));
}
}
#[test]
fn op_atom_pcm_helper() {
let ids = PackageIds::from_seed("atom-a", "Atom A1");
let samples: Vec<i32> = (0..4801).map(|k| (k * 7 % 65536) - 32768).collect();
for rate in [None, Some(Rational::new(25, 1))] {
let opts = OpAtomPcm { sample_rate: 48_000, bits: 16, channels: 1, edit_rate: rate, ids: ids.clone(), timecode: None };
let f = write_opatom_pcm(&opts, &samples).unwrap();
let m = open(&f).unwrap();
assert_eq!(m.operational_pattern, OperationalPattern::Atom);
let t = &m.tracks[0];
assert_eq!(t.kind, TrackKind::Sound);
assert_eq!(t.stored_sample_frames(), 4801);
assert_eq!(t.duration, Some(if rate.is_some() { 3 } else { 4801 }));
let pcm = m.read_pcm(&f, 0, 0, 4801).unwrap();
assert!(samples.iter().zip(&pcm[0]).all(|(s, p)| *s as f32 / 32768.0 == *p));
}
// 24-bit stereo
let opts = OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 2, edit_rate: None, ids, timecode: None };
let f = write_opatom_pcm(&opts, &[8_388_607, -8_388_608, 1, -1]).unwrap();
let m = open(&f).unwrap();
let pcm = m.read_pcm(&f, 0, 0, 2).unwrap();
assert_eq!(pcm[0], vec![8_388_607.0 / 8_388_608.0, 1.0 / 8_388_608.0]);
assert_eq!(pcm[1], vec![-1.0, -1.0 / 8_388_608.0]);
}
#[test]
fn package_ids_are_written() {
let f = build_op1a();
let ids = PackageIds::from_seed("test", "Test Clip");
assert!(f.windows(32).any(|w| w == ids.material.0));
assert!(f.windows(32).any(|w| w == ids.file.0));
assert_ne!(ids.material, ids.file);
assert_eq!(Umid::from_seed(b"x"), Umid::from_seed(b"x"));
assert_ne!(Umid::from_seed(b"x"), Umid::from_seed(b"y"));
}
#[test]
fn long_files_split_the_index_into_segments() {
let mut cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 4 });
cfg.sound.clear();
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
for i in 0..12_000 {
w.push_picture(&[0, 0, 0, 9, b'i', b'c', b'p', b'f', i as u8], FrameInfo::intra()).unwrap();
}
let f = w.finish().unwrap().into_inner();
let m = open(&f).unwrap();
assert!(m.index_segments.len() >= 2);
assert_eq!(m.index_segments.iter().map(|s| s.entries.len()).sum::<usize>(), 12_000);
assert_eq!(m.tracks[0].samples.len(), 12_000);
assert_eq!(m.read_sample(&f, 0, 11_999).unwrap()[8], (11_999 % 256) as u8);
}
#[test]
fn rejects_bad_configs() {
let ids = PackageIds::from_seed("x", "x");
let cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids.clone());
assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err());
let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(25, 1), ids);
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 1, bits: 16 }; 2];
assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err());
}
#[test]
fn timestamps() {
assert_eq!(Timestamp::from_unix(0), Timestamp { year: 1970, month: 1, day: 1, ..Default::default() });
let t = Timestamp::from_unix(1_700_000_000);
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20));
let t = Timestamp::from_unix(951_782_400); // 2000-02-29
assert_eq!((t.year, t.month, t.day), (2000, 2, 29));
}
#[test]
fn truncated_and_mutated_files_never_panic() {
let f = build_op1a();
let mut opened = 0;
for cut in (0..f.len()).step_by(41) {
let part = f[..cut].to_vec();
if let Ok(m) = open(&part) {
opened += 1;
for (ti, t) in m.tracks.iter().enumerate() {
for i in 0..t.samples.len() {
assert!(m.read_sample(&part, ti, i).is_ok(), "cut {cut}");
}
let _ = m.read_pcm(&part, ti, 0, 3000);
}
}
}
assert!(opened > 20, "a truncated file with a complete header still opens ({opened})");
let atom = write_opatom_pcm(
&OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 1, edit_rate: None, ids: PackageIds::from_seed("m", "m"), timecode: None },
&(0..3000).collect::<Vec<i32>>(),
)
.unwrap();
let mut seed = 0x9E37_79B9u64;
for src in [&f, &atom] {
for cut in (0..src.len()).step_by(53) {
let _ = open(&src[..cut].to_vec()).map(|m| m.read_pcm(&src[..cut].to_vec(), 0, 0, 100).ok());
}
for _ in 0..400 {
let mut g = src.clone();
for _ in 0..6 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let at = (seed as usize) % g.len();
g[at] = (seed >> 40) as u8;
}
if let Ok(m) = open(&g) {
for (ti, t) in m.tracks.iter().enumerate() {
for i in 0..t.samples.len().min(12) {
let _ = m.read_sample(&g, ti, i);
}
let _ = m.read_pcm(&g, ti, 0, 500);
}
}
}
}
}

View File

@ -40,4 +40,5 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
[dev-dependencies]
pollster = "0.4"
filmcraft-codecs = { workspace = true }
filmcraft-interchange = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }

View File

@ -665,7 +665,7 @@ impl FilmcraftApp {
.iter()
.chain(filmcraft_media::AUDIO_EXTENSIONS)
.chain(filmcraft_media::STILL_EXTENSIONS)
.chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio"])
.chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"])
.copied()
.collect();
let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default();

View File

@ -286,6 +286,11 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
crate::panels::shortcuts_dialog::open(app);
return Ok(Value::Null);
}
// File ▸ Export ▸ AAF… / OMF… open their settings dialogs; with a path they export directly.
"file.exportAaf" | "file.exportOmf" if params.get("path").is_none() => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
return crate::panels::interchange_export::open(app, ctx, id);
}
// Audio Gain from the menu or G opens the dialog; with params it applies directly.
"clip.audioGain" if params.as_object().is_none_or(|m| m.is_empty()) => {
filmcraft_engine::find_command("clip.audioGain").map_or(Ok(()), |c| (c.enabled)(&app.session))?;

View File

@ -16,6 +16,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::monitor_view::dialogs(app, ctx);
crate::panels::voiceover::show(app, ctx);
crate::panels::remix::show(app, ctx);
crate::panels::interchange_export::show(app, ctx);
crate::panels::project_dialogs::show(app, ctx);
crate::panels::media_browser::dialogs(app, ctx);
let Some(d) = app.dialog else { return };

View File

@ -22,7 +22,8 @@
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_engine::export::{
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, Placement, Scaling, TextOverlay, builtin_presets, format_bytes,
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, MxfVideoCodec, Placement, Scaling, TextOverlay, builtin_presets,
format_bytes,
};
use filmcraft_engine::time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
@ -458,7 +459,11 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) {
video_section(ui, &mut reg, s, &t, seq_w, seq_h);
}
if (s.has_audio() || !s.has_video() || s.format == Format::H264 || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg))
if (s.has_audio()
|| !s.has_video()
|| s.format == Format::H264
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg)
|| s.format.is_mxf())
&& !s.is_image_sequence()
&& s.format != Format::Gif
&& section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t)
@ -579,7 +584,15 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
s.pixel_aspect = PARS[i].1;
}
});
match s.format {
if s.format.is_mxf() {
row(ui, t, "Video Codec", |ui| {
let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect();
if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) {
s.mxf_video_codec = MxfVideoCodec::ALL[i];
}
});
}
match s.video_format() {
Format::H264 => {
row(ui, t, "Profile", |ui| {
let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High];
@ -683,7 +696,7 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
}
}
row(ui, t, "Audio Format", |ui| {
let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only;
let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf();
let cur = match s.audio_codec() {
AudioCodec::Aac => "AAC",
_ => "Uncompressed (PCM)",

View File

@ -0,0 +1,207 @@
//! File ▸ Export ▸ AAF… and OMF…: the export settings dialogs. OK asks for the file name (the
//! host's save panel) and runs `file.exportAaf` / `file.exportOmf` with the chosen settings; with
//! parameters (`path` …) the menu commands run directly.
//!
//! Automation ids (`<p>` is `aafExport` or `omfExport`): `<p>.mixdownVideo` (AAF),
//! `<p>.breakoutToMono`, `<p>.audio.<embedded|separate|linked>` (linked: AAF only),
//! `<p>.audioFormat.<wav|aiff|mxf>` (mxf: AAF only), `<p>.sampleRate.<rate>`, `<p>.bitDepth.<16|24>`, `<p>.trimAudio`,
//! `<p>.handles` (frames), `<p>.renderAudioEffects`, `<p>.title` (OMF), `<p>.ok`, `<p>.cancel`.
use serde_json::{Value, json};
use crate::FilmcraftApp;
#[derive(Clone, Debug, PartialEq)]
struct Draft {
omf: bool,
title: String,
mixdown_video: bool,
breakout: bool,
audio: &'static str,
format: &'static str,
sample_rate: u32,
bits: u16,
trim: bool,
handles: i64,
render_effects: bool,
}
fn draft_id() -> egui::Id {
egui::Id::new("interchange-export-draft")
}
const RATES: [u32; 4] = [32_000, 44_100, 48_000, 96_000];
/// Open the AAF or OMF settings dialog (`id` is the menu command).
pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Result<Value, String> {
let omf = id == "file.exportOmf";
let seq = app.session.state.active_sequence.and_then(|s| app.session.project.item(s));
let title = seq.map(|i| i.name.clone()).unwrap_or_default();
let sr = app.session.active_sequence().map(|q| q.settings.sample_rate).filter(|r| RATES.contains(r)).unwrap_or(48_000);
let d = Draft {
omf,
title,
mixdown_video: false,
breakout: false,
audio: "embedded",
format: "wav",
sample_rate: sr,
bits: 16,
trim: omf,
handles: if omf { 30 } else { 0 },
render_effects: false,
};
ctx.data_mut(|m| m.insert_temp(draft_id(), Some(d)));
Ok(json!({"dialog": if omf { "omfExport" } else { "aafExport" }}))
}
/// Whether the dialog is open.
pub fn is_open(ctx: &egui::Context) -> bool {
ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())).flatten().is_some()
}
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
let p = if d.omf { "omfExport" } else { "aafExport" };
let mut close = false;
let mut apply = false;
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" };
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
ui.set_min_width(380.0);
egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if d.omf {
ui.label("OMF Title:");
let r = ui.text_edit_singleline(&mut d.title);
elems.push((format!("{p}.title"), r.rect, d.title.clone()));
ui.end_row();
} else {
ui.label("");
let r = ui.checkbox(&mut d.mixdown_video, "Mixdown video");
elems.push((format!("{p}.mixdownVideo"), r.rect, d.mixdown_video.to_string()));
ui.end_row();
}
ui.label("Audio:");
ui.horizontal(|ui| {
let modes: &[(&'static str, &str)] = if d.omf {
&[("embedded", "Encapsulate"), ("separate", "Separate Audio")]
} else {
&[("embedded", "Embed"), ("separate", "Separate Files"), ("linked", "Link to Media")]
};
for (k, label) in modes {
let r = ui.radio(d.audio == *k, *label);
if r.clicked() {
d.audio = k;
}
elems.push((format!("{p}.audio.{k}"), r.rect, label.to_string()));
}
});
ui.end_row();
ui.label("Audio File Format:");
ui.horizontal(|ui| {
let formats: &[(&'static str, &str)] =
if d.omf { &[("wav", "Broadcast Wave"), ("aiff", "AIFF")] } else { &[("wav", "Broadcast Wave"), ("aiff", "AIFF"), ("mxf", "OP-Atom MXF")] };
for &(k, label) in formats {
let r = ui.add_enabled(d.audio == "separate", egui::RadioButton::new(d.format == k, label));
if r.clicked() {
d.format = k;
}
elems.push((format!("{p}.audioFormat.{k}"), r.rect, label.to_string()));
}
});
ui.end_row();
ui.label("Sample Rate:");
ui.horizontal(|ui| {
for r in RATES {
let b = ui.radio(d.sample_rate == r, format!("{r}"));
if b.clicked() {
d.sample_rate = r;
}
elems.push((format!("{p}.sampleRate.{r}"), b.rect, format!("{r} Hz")));
}
});
ui.end_row();
ui.label("Bits per Sample:");
ui.horizontal(|ui| {
for b in [16u16, 24] {
let r = ui.radio(d.bits == b, format!("{b}"));
if r.clicked() {
d.bits = b;
}
elems.push((format!("{p}.bitDepth.{b}"), r.rect, format!("{b}-bit")));
}
});
ui.end_row();
ui.label("Rendering:");
let linked = d.audio == "linked";
let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, "Trim audio files"));
elems.push((format!("{p}.trimAudio"), r.rect, d.trim.to_string()));
ui.end_row();
ui.label("Handle Frames:");
let r = ui.add_enabled(d.trim && !linked, egui::DragValue::new(&mut d.handles).range(0..=10_000));
elems.push((format!("{p}.handles"), r.rect, d.handles.to_string()));
ui.end_row();
ui.label("");
let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, "Render audio clip effects"));
elems.push((format!("{p}.renderAudioEffects"), r.rect, d.render_effects.to_string()));
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.breakout, "Breakout to mono");
elems.push((format!("{p}.breakoutToMono"), r.rect, d.breakout.to_string()));
ui.end_row();
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let c = ui.button("Cancel");
elems.push((format!("{p}.cancel"), c.rect, "Cancel".into()));
if c.clicked() {
close = true;
}
let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent));
elems.push((format!("{p}.ok"), o.rect, "OK".into()));
if o.clicked() {
apply = true;
}
});
});
for (id, r, l) in elems {
app.auto.add(&id, r, &l);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
if apply {
close = true;
let (ext, filter) = if d.omf { ("omf", "OMF") } else { ("aaf", "AAF") };
let stem = if d.title.is_empty() { "Sequence".to_string() } else { d.title.replace(['/', '\\', ':'], "_") };
let suggested = format!("{stem}.{ext}");
match app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &suggested)) {
Some(path) => {
let mut params = json!({
"path": path,
"breakoutToMono": d.breakout,
"audio": d.audio,
"audioFormat": d.format,
"sampleRate": d.sample_rate,
"bitDepth": d.bits,
"trimAudio": d.trim && d.audio != "linked",
"handles": d.handles,
"renderAudioEffects": d.render_effects && d.audio != "linked",
});
if d.omf {
params["title"] = json!(d.title);
} else {
params["mixdownVideo"] = json!(d.mixdown_video);
}
let cmd = if d.omf { "file.exportOmf" } else { "file.exportAaf" };
match app.session.execute(cmd, params) {
Ok(v) => app.ui.status = format!("Exported {}", v["path"].as_str().unwrap_or_default()),
Err(e) => app.ui.status = e.to_string(),
}
}
None if app.hooks.pick_save_as.is_none() => app.ui.status = "no save dialog available: run the command with a `path`".into(),
None => {}
}
}
ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) }));
}

View File

@ -14,6 +14,7 @@ pub mod file_dialogs;
pub mod graphics;
pub mod graphics_templates;
pub mod import_mode;
pub mod interchange_export;
pub mod keyboard;
pub mod lumetri;
pub mod masks;

View File

@ -0,0 +1,168 @@
//! Headless UI tests of File ▸ Export ▸ AAF… / OMF…: the settings dialogs, driven by automation id
//! through the control channel, export through the host's save panel.
use std::sync::Arc;
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn new(session: Session, save_to: std::path::PathBuf) -> Self {
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(session).with_control(rx);
app.hooks.pick_save_as = Some(Box::new(move |_, exts, suggested| {
assert!(suggested.ends_with(&format!(".{}", exts[0])), "{suggested}");
Some(save_to.join(suggested).to_string_lossy().into_owned())
}));
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn ids(&mut self, prefix: &str) -> Vec<String> {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
}
/// A sequence "Mix" with one second of a 440 Hz tone on A1.
fn session() -> Session {
let mut s = Session::default();
s.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap();
let inter: Vec<f32> = (0..48_000)
.flat_map(|i| {
let x = (i as f32 * 440.0 * std::f32::consts::TAU / 48_000.0).sin() * 0.5;
[x, x]
})
.collect();
let bytes: Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into();
let item = filmcraft_engine::commands::import_bytes(&mut s, "/tone.wav", bytes, None).unwrap();
s.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
s
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("fc-ix-ui-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn aaf_and_omf_export_dialogs() {
let dir = tmp("dialogs");
let mut d = Driver::new(session(), dir.clone());
let items = d.ok("ui.menu.list", json!({}));
let menu: Vec<(String, Vec<String>)> = items
.as_array()
.unwrap()
.iter()
.map(|i| (i["id"].as_str().unwrap().to_string(), i["path"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect()))
.collect();
for id in ["file.exportAaf", "file.exportOmf"] {
let (_, path) = menu.iter().find(|(i, _)| i == id).unwrap_or_else(|| panic!("{id} missing from the menus"));
assert_eq!(path, &vec!["File".to_string(), "Export".to_string()]);
}
// AAF: the dialog lists Premiere's options; OK asks for the file and exports
let r = d.ok("ui.menu.invoke", json!({"id": "file.exportAaf"}));
assert_eq!(r["dialog"], "aafExport");
d.frames(3);
let ids = d.ids("aafExport.");
for id in [
"mixdownVideo",
"breakoutToMono",
"audio.embedded",
"audio.separate",
"audio.linked",
"audioFormat.wav",
"sampleRate.48000",
"bitDepth.24",
"trimAudio",
"handles",
"renderAudioEffects",
"ok",
"cancel",
] {
assert!(ids.contains(&format!("aafExport.{id}")), "aafExport.{id} missing: {ids:?}");
}
d.ok("ui.click", json!({"id": "aafExport.bitDepth.24"}));
d.ok("ui.click", json!({"id": "aafExport.breakoutToMono"}));
d.frames(2);
d.ok("ui.click", json!({"id": "aafExport.ok"}));
d.frames(4);
assert!(d.ids("aafExport.").is_empty(), "the dialog closes");
let aaf = std::fs::read(dir.join("Mix.aaf")).expect("Mix.aaf written");
assert!(filmcraft_interchange::aaf::sniff(&aaf));
let (imp, extracted, _) = filmcraft_interchange::aaf::import(&aaf, &Default::default()).unwrap();
assert_eq!(extracted.len(), 2, "embedded, broken out to two mono essences");
let (_, ch, sr, bits) = filmcraft_interchange::wav::parse_wav(&extracted[0].wav).unwrap();
assert_eq!((ch, sr, bits), (1, 48_000, 24));
assert_eq!(imp.sequences.len(), 1);
// OMF: cancel leaves nothing behind; OK with separate AIFF files
d.ok("ui.menu.invoke", json!({"id": "file.exportOmf"}));
d.frames(3);
let ids = d.ids("omfExport.");
assert!(ids.contains(&"omfExport.title".to_string()) && !ids.contains(&"omfExport.audio.linked".to_string()), "{ids:?}");
d.ok("ui.click", json!({"id": "omfExport.cancel"}));
d.frames(3);
assert!(!dir.join("Mix.omf").exists());
d.ok("ui.menu.invoke", json!({"id": "file.exportOmf"}));
d.frames(3);
d.ok("ui.click", json!({"id": "omfExport.audio.separate"}));
d.frames(2);
d.ok("ui.click", json!({"id": "omfExport.audioFormat.aiff"}));
d.frames(2);
d.ok("ui.click", json!({"id": "omfExport.ok"}));
d.frames(4);
let omf = std::fs::read(dir.join("Mix.omf")).expect("Mix.omf written");
assert!(filmcraft_interchange::omf::sniff(&omf));
let audio_dir = dir.join("Mix Audio Files");
let aifs: Vec<_> = std::fs::read_dir(&audio_dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().extension().is_some_and(|x| x == "aif")).collect();
assert_eq!(aifs.len(), 1, "one trimmed AIFF");
// with a path the menu command exports directly (agents)
let p = dir.join("direct.aaf").to_string_lossy().into_owned();
let v = d.ok("ui.menu.invoke", json!({"id": "file.exportAaf", "params": {"path": p, "audio": "linked"}}));
assert!(v.get("dialog").is_none(), "{v}");
assert!(std::path::Path::new(&p).exists());
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -27,7 +27,7 @@ Design principles:
L2 edit · codecs · interchange · captions · speech
L1 frame · media · project · audio-dsp · text
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
codecs/containers: isobmff · matroska · mxf · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
```
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
@ -41,7 +41,8 @@ and `filmcraft-cli`.
| `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention |
| `isobmff` | L0 | MP4/MOV demux and mux |
| `matroska` | L0 | MKV/WebM demux |
| `mxf` | L0 | MXF demux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode ([README](../crates/mxf/README.md)) |
| `mxf` | L0 | MXF demux and mux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode; writer for OP1a / OP-Atom with VC-3, ProRes, AVC and PCM ([README](../crates/mxf/README.md)) |
| `cfb` | L0 | Compound File Binary ([MS-CFB] structured storage) reader and writer, the container of AAF ([README](../crates/cfb/README.md)) |
| `mpegts` | L0 | MPEG-2 Systems demux (H.222.0): transport streams (188 / 192-byte BDAV / 204), program streams (MPEG-2 / MPEG-1, VOB, MOD), PSI, PES, PTS / PCR, access-unit index built on open ([README](../crates/mpegts/README.md)) |
| `ogg` | L0 | Ogg demux (RFC 3533), Ogg Opus timing (RFC 7845 granules, pre-skip, end trimming), Vorbis headers |
| `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder |
@ -61,7 +62,7 @@ and `filmcraft-cli`.
| `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…; text-based editing: `edit::transcript`) |
| `speech` | L2 | speech-to-text: `Transcriber` trait, Whisper model catalogue + verified downloader (feature `download`), pure-Rust Whisper inference on candle with word timestamps (feature `whisper`), speaker labelling ([transcripts.md](transcripts.md)) |
| `codecs` | L2 | container + codec hub: MP4/MOV, MKV, MXF, Ogg and MPEG TS / PS / video elementary stream sources, GOP-aware seeking, decoder registry, audio decoding |
| `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) |
| `interchange` | L2 | EDL, FCP7 XML, FCPXML, OTIO, AAF (on `cfb`) and OMF 2.0 import/export (no file I/O; the engine supplies rendered audio essence) ([README](../crates/interchange/README.md)) |
| `render` | L3 | sequence evaluation, CPU compositor, video effects (`effects`, `vfx`; effects needing other frames or tracks read them through `vfx::FxEnv`), transitions, audio mix |
| `gpu` | L3 | wgpu compositor (WGSL) |
| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only |
@ -559,6 +560,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV |
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
| `mjpeg` | built in | MOV |
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
@ -571,7 +574,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.
- **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive
H.264 at 0.2 / 0.1 / 0.05 bits per pixel, 1080p / 2160p delivery, vertical 1080×1920, ProRes,
DNxHR, image sequences, GIF, WAV / AIFF). User presets and favourites persist in
DNxHR, MXF OP1a (DNxHR HQ / ProRes 422 HQ / H.264) and OP-Atom (DNxHR, Avid), image sequences, GIF, WAV / AIFF). User presets and favourites persist in
`<data dir>/export-presets.json` (`export.presets.*`).
- **Queue.** `export.queue.add` snapshots the project and the resolved settings (several sequences
or ranges add several items); `export.queue.start` encodes ready items one after another as
@ -582,9 +585,14 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
Video encoders implement `export::VideoEncoder`. Codec crates plug in with `register_encoder` and
`register_audio_encoder`.
Timelines can be exchanged as EDL, FCP7 XML, FCPXML or OTIO. `file.import` detects these formats and
merges the result into the project as one undoable step, and `file.exportInterchange`,
`file.exportEdl`, `file.exportFcpxml` and `file.exportOtio` write them.
Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these
formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is
written next to the document first), and `file.exportInterchange`, `file.exportEdl`,
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. For AAF and
OMF the engine (`engine::aaf_omf`) first prepares the audio the document references: it lists the
used ranges (`interchange::essence::audio_needs`), decodes or renders them (clip effects through the
export audio pipeline), embeds them or writes WAV / AIFF files, and optionally renders a video
mixdown.
## 7. Automation surfaces

View File

@ -31,6 +31,7 @@ const LAYERS: &[(&str, u8)] = &[
("isobmff", 0),
("matroska", 0),
("mxf", 0),
("cfb", 0),
("mpegts", 0),
("mpeg2v", 0),
("ac3", 0),