From 4f096b64d60e53c62a2e73d3a39652d75ccb5087 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:52:44 -0500 Subject: [PATCH] craft-aaf: AAF and OMF readers and writers over one composition model (from FilmCraft) (#918) * craft-audio: integer PCM of WAV and AIFF files bit for bit, and minimal files around it wav_pcm and aiff_pcm (feature decode-native) return a file's integer PCM as stored (RawPcm: little-endian interleaved bytes, 8-bit unsigned, with channels, rate and bits), built on the existing WAV and AIFF chunk parsers. pcm_wav_file, pcm_wav_header, pcm_aiff_file and pcm_aiff_header (feature pcm-encode) write the 44-byte canonical WAVE header and the plain AIFF header around such bytes. For essence embedded in AAF and OMF files, which is moved bit for bit and whose descriptors hold a bare header as a summary. The writers are FilmCraft's (filmcraft-interchange wav.rs); the readers replace its second WAV/AIFF parser with this crate's. * craft-timeline: exact media time, reports, media paths, ALE and CMX 3600 EDL New crate for editorial timeline interchange (#782), moved from FilmCraft at 4bec22bd: - time: filmcraft-time whole (Tick, FrameRate, TimeRange, SMPTE timecode, feet+frames, typed-timecode parsing) with its tests. - report: Report / Level / ReportEntry. - path: file URL and path conversion, relative references joined to a folder. - rate: timebase+ntsc and floating rates to exact rates, MAX_RATE. - ale: AleDoc, parse, write, standard heading and columns. - edl: the parser (events, lines, timecode fields, M2, AUD, comment extensions), the frame-rate guess, and the line layout of the writer, on frame counts. - Format, detect_text, decode_text. Each format module gives the document as the format writes it. Mapping onto an application's timeline stays in the application. * craft-timeline: strip a UTF-8 byte order mark when parsing ALE files An ALE saved with a BOM was refused as "not an ALE file": the mark made the first line something other than "Heading". Ported from storytold/filmcraft#4531. * craft-aaf: AAF (Edit Protocol) and OMF 2.0 readers and writers over one composition model New crate (#782), moved from FilmCraft's filmcraft-interchange at 4bec22bd: the mob-style model types of comp.rs, aaf/{ids,store,read,write}.rs and omf/{bento,read,write}.rs, with their structural and hostile-input tests rebuilt on model documents instead of FilmCraft projects. The container is craft-cfb, time and reports are craft-timeline's, and the WAVE/AIFF wrappers of embedded PCM are craft-audio's. The writer's identity (company, product, version, AAF product id) is passed in instead of being FilmCraft's. The private tags and properties FilmCraft wrote keep their names so existing files read back. The mapping between the model and an application's project stays in the application. --------- Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> --- Cargo.lock | 10 + Cargo.toml | 1 + README.md | 1 + crates/aaf/Cargo.toml | 18 + crates/aaf/README.md | 67 +++ crates/aaf/src/aaf.rs | 84 +++ crates/aaf/src/aaf/hostile_tests.rs | 110 ++++ crates/aaf/src/aaf/ids.rs | 262 +++++++++ crates/aaf/src/aaf/read.rs | 722 +++++++++++++++++++++++++ crates/aaf/src/aaf/store.rs | 538 +++++++++++++++++++ crates/aaf/src/aaf/tests.rs | 467 ++++++++++++++++ crates/aaf/src/aaf/write.rs | 800 ++++++++++++++++++++++++++++ crates/aaf/src/lib.rs | 60 +++ crates/aaf/src/model.rs | 185 +++++++ crates/aaf/src/omf.rs | 47 ++ crates/aaf/src/omf/bento.rs | 407 ++++++++++++++ crates/aaf/src/omf/read.rs | 438 +++++++++++++++ crates/aaf/src/omf/tests.rs | 135 +++++ crates/aaf/src/omf/write.rs | 419 +++++++++++++++ crates/aaf/src/pcm.rs | 11 + crates/aaf/src/roundtrip_tests.rs | 217 ++++++++ crates/aaf/src/testdoc.rs | 106 ++++ 22 files changed, 5105 insertions(+) create mode 100644 crates/aaf/Cargo.toml create mode 100644 crates/aaf/README.md create mode 100644 crates/aaf/src/aaf.rs create mode 100644 crates/aaf/src/aaf/hostile_tests.rs create mode 100644 crates/aaf/src/aaf/ids.rs create mode 100644 crates/aaf/src/aaf/read.rs create mode 100644 crates/aaf/src/aaf/store.rs create mode 100644 crates/aaf/src/aaf/tests.rs create mode 100644 crates/aaf/src/aaf/write.rs create mode 100644 crates/aaf/src/lib.rs create mode 100644 crates/aaf/src/model.rs create mode 100644 crates/aaf/src/omf.rs create mode 100644 crates/aaf/src/omf/bento.rs create mode 100644 crates/aaf/src/omf/read.rs create mode 100644 crates/aaf/src/omf/tests.rs create mode 100644 crates/aaf/src/omf/write.rs create mode 100644 crates/aaf/src/pcm.rs create mode 100644 crates/aaf/src/roundtrip_tests.rs create mode 100644 crates/aaf/src/testdoc.rs diff --git a/Cargo.lock b/Cargo.lock index c66f7920..36919285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,16 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-aaf" +version = "0.1.0" +dependencies = [ + "craft-audio", + "craft-cfb", + "craft-timeline", + "thiserror 2.0.21", +] + [[package]] name = "craft-aep" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9adfeca7..b77c6582 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std" bytemuck = "1" kurbo = "0.13" thiserror = "2" +craft-aaf = { path = "crates/aaf" } craft-atomic = { path = "crates/atomic" } craft-captions = { path = "crates/captions" } craft-cfb = { path = "crates/cfb" } diff --git a/README.md b/README.md index 58d0758d..87ccc785 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " { + pub identity: Identity<'a>, + /// Write 512-byte sectors (compound file version 3) instead of 4096-byte sectors. + pub small_sectors: bool, +} + +/// Write `doc` as an AAF file: its first composition is the top-level one, with every nested +/// composition and every source it uses. Times that are not on whole edit units are rounded and +/// reported. +pub fn write(doc: &Document, opts: &WriteOptions<'_>, report: &mut Report) -> Result> { + let version = if opts.small_sectors { craft_cfb::Version::V3 } else { craft_cfb::Version::V4 }; + write::write(doc, version, &opts.identity, report) +} + +/// Read an AAF file: its top-level compositions, the compositions nested in them and their +/// sources. What cannot be represented is skipped and named in `report`. +pub fn read(bytes: &[u8], report: &mut Report) -> Result { + read::read(bytes, 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 !craft_cfb::sniff(bytes) { + return false; + } + match craft_cfb::CompoundFile::open(bytes) { + Ok(cf) => cf.root().clsid == ids::ROOT || cf.find("Header-2").is_ok(), + Err(_) => false, + } +} + +#[cfg(test)] +mod hostile_tests; +#[cfg(test)] +mod tests; diff --git a/crates/aaf/src/aaf/hostile_tests.rs b/crates/aaf/src/aaf/hostile_tests.rs new file mode 100644 index 00000000..853b0000 --- /dev/null +++ b/crates/aaf/src/aaf/hostile_tests.rs @@ -0,0 +1,110 @@ +//! Hostile object trees: a crafted file must give an error, never a crash, a hang or an +//! allocation sized by a count in the file (AGENTS.md §0). + +use craft_cfb::{Version, Writer}; + +use super::store::{self, SF_STRONG_VECTOR, SF_WEAK_SET, SF_WEAK_VECTOR, Value, utf16z}; + +const PID: u16 = 0x0007; + +/// A root object whose one property `PID` is a weak reference vector or set (stored form `sf`) +/// named `Keys`, with `index` as its `Keys index` stream. +fn weak_collection_file(sf: u16, index: Vec) -> Vec { + let name = utf16z("Keys"); + // byte order 'L', format version, 1 entry { pid, stored form, value length }, the value + let mut props = vec![0x4C, 0x20]; + for x in [1, PID, sf, name.len() as u16] { + props.extend_from_slice(&x.to_le_bytes()); + } + props.extend_from_slice(&name); + let mut w = Writer::new(Version::V3); + w.stream(Writer::ROOT, "properties", props).unwrap(); + w.stream(Writer::ROOT, "Keys index", index).unwrap(); + w.finish() +} + +/// A weak collection index: u32 count, u16 tag, u16 key pid, u8 key size, then the keys. +fn index(count: u32, size: u8, keys: &[u8]) -> Vec { + let mut v = count.to_le_bytes().to_vec(); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&0x1B01u16.to_le_bytes()); + v.push(size); + v.extend_from_slice(keys); + v +} + +fn read_keys(sf: u16, index: Vec) -> Result>, String> { + match store::read(&weak_collection_file(sf, index))?.get(PID) { + Some(Value::WeakVec(_, _, keys)) => Ok(keys.clone()), + other => panic!("not a weak collection: {other:?}"), + } +} + +/// With a key size of 0 every key fits, so the count alone drove the loop: up to 4.29 billion +/// empty keys (about 100 GB) from a 9-byte index. A million is enough to show it, cheaply. +#[test] +fn zero_size_weak_collection_keys_are_an_error() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + match read_keys(sf, index(1_000_000, 0, &[])) { + Err(e) => assert!(e.contains("zero-size"), "{e}"), + Ok(keys) => panic!("read {} empty keys instead of an error", keys.len()), + } + } +} + +#[test] +fn weak_collection_count_beyond_the_index_is_an_error() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + let e = read_keys(sf, index(u32::MAX, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap_err(); + assert!(e.contains("short weak collection index"), "{e}"); + } +} + +#[test] +fn weak_collections_within_the_index_are_read() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + assert_eq!(read_keys(sf, index(0, 0, &[])).unwrap(), Vec::>::new()); + assert_eq!(read_keys(sf, index(2, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap(), [vec![1, 2, 3, 4], vec![5, 6, 7, 8]]); + } +} + +/// A strong vector whose entries all name the same child storage would rebuild that subtree once +/// per entry; a storage has one owner, so the repeat is an error. +#[test] +fn a_storage_owned_twice_is_an_error() { + let name = utf16z("Items"); + let mut props = vec![0x4C, 0x20]; + for x in [1, PID, SF_STRONG_VECTOR, name.len() as u16] { + props.extend_from_slice(&x.to_le_bytes()); + } + props.extend_from_slice(&name); + // count, next free key, last key, then the entry keys + let mut idx = 3u32.to_le_bytes().to_vec(); + idx.extend_from_slice(&[0; 8]); + for _ in 0..3 { + idx.extend_from_slice(&1u32.to_le_bytes()); + } + let mut w = Writer::new(Version::V3); + w.stream(Writer::ROOT, "properties", props).unwrap(); + w.stream(Writer::ROOT, "Items index", idx).unwrap(); + let child = w.storage(Writer::ROOT, "Items{1}", [0; 16]).unwrap(); + w.stream(child, "properties", vec![0x4C, 0x20, 0, 0]).unwrap(); + let e = store::read(&w.finish()).unwrap_err(); + assert!(e.contains("more than one reference"), "{e}"); +} + +/// Objects nest through strong references; a chain deeper than the reader follows is an error, +/// and one within it reads. +#[test] +fn objects_nested_too_deeply_are_an_error() { + let chain = |depth: usize| { + let mut o = store::Obj::new([1; 16]); + for _ in 0..depth { + o = store::Obj::new([1; 16]).with(PID, Value::Strong(Box::new(o))); + } + store::write(&o, Version::V4).unwrap() + }; + assert!(store::read(&chain(150)).is_ok()); + let e = store::read(&chain(250)).unwrap_err(); + assert!(e.contains("nested too deeply"), "{e}"); +} diff --git a/crates/aaf/src/aaf/ids.rs b/crates/aaf/src/aaf/ids.rs new file mode 100644 index 00000000..70a18406 --- /dev/null +++ b/crates/aaf/src/aaf/ids.rs @@ -0,0 +1,262 @@ +//! Class ids, property ids and definition ids of the AAF object model subset read 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 SCOPE_REFERENCE: Auid = class(0x0D); + 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 read 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; + // MetaDictionary and MetaDefinition + pub const META_CLASS_DEFINITIONS: u16 = 0x0003; + pub const META_TYPE_DEFINITIONS: u16 = 0x0004; + pub const META_IDENTIFICATION: u16 = 0x0005; + // 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 (`AAFEditProtocol`) and mob usage codes (`Usage_TopLevel`), + /// as in the SMPTE Labels register. + 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, 0x01, 0x0D, 0x01, 0x01, 0x02, 0x01, 0x01, 0x07, 0x00]); + /// The label FilmCraft 0.16 and earlier wrote as the top-level usage code (in no register). + /// Import reads it as top-level too. + pub const USAGE_TOP_LEVEL_OLD: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x05, 0x0D, 0x01, 0x12, 0x01, 0x02, 0x03, 0x00, 0x00]); +} + +/// Weak reference target paths (pids from the root object). +pub(crate) mod path { + use super::pid; + pub const DATA_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::DATA_DEFINITIONS]; + pub const OPERATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::OPERATION_DEFINITIONS]; + pub const PARAMETER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::PARAMETER_DEFINITIONS]; + pub const INTERPOLATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::INTERPOLATION_DEFINITIONS]; + pub const CONTAINER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::CONTAINER_DEFINITIONS]; +} diff --git a/crates/aaf/src/aaf/read.rs b/crates/aaf/src/aaf/read.rs new file mode 100644 index 00000000..206c2ead --- /dev/null +++ b/crates/aaf/src/aaf/read.rs @@ -0,0 +1,722 @@ +//! AAF objects → composition model. + +use std::collections::{HashMap, HashSet}; + +use craft_timeline::time::{FrameRate, Tick}; + +use super::ids::{cls, ddef, def, pid}; +use super::store::{self, Auid, Obj, Value, rational_of, utf16_of}; +use crate::model::{CClip, CItem, CKind, CMarker, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks}; +use crate::{Error, Report, Result}; + +type MobId = [u8; 32]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DataKind { + Picture, + Sound, + Timecode, + Other, +} + +fn data_kind(o: &Obj) -> DataKind { + match o.weak_key(pid::DATA_DEFINITION).and_then(|k| ::try_from(k).ok()) { + Some(k) if k == ddef::PICTURE || k == ddef::LEGACY_PICTURE => DataKind::Picture, + Some(k) if k == ddef::SOUND || k == ddef::LEGACY_SOUND => DataKind::Sound, + Some(k) if k == ddef::TIMECODE || k == ddef::LEGACY_TIMECODE => DataKind::Timecode, + Some(_) => DataKind::Other, + None => DataKind::Other, + } +} + +fn len_of(o: &Obj) -> i64 { + o.i64(pid::LENGTH).unwrap_or(0).max(0) +} + +/// The value of an indirect property: (type id, bytes). +fn indirect(d: &[u8]) -> Option<(Auid, &[u8])> { + if d.len() >= 17 && (d[0] == 0x4C || d[0] == 0x42) { + return Some((d[1..17].try_into().ok()?, &d[17..])); + } + if d.len() >= 16 { + return Some((d[..16].try_into().ok()?, &d[16..])); + } + None +} + +fn tagged_values(o: &Obj, p: u16) -> Vec<(String, String)> { + o.objs(p) + .iter() + .filter_map(|t| { + let name = t.string(pid::TAG_NAME)?; + let (ty, v) = indirect(t.data(pid::TAG_VALUE)?)?; + let value = if ty == def::TYPE_STRING || v.len() % 2 == 0 { utf16_of(v) } else { String::new() }; + Some((name, value)) + }) + .collect() +} + +fn indirect_f64(d: &[u8]) -> Option { + let (_, v) = indirect(d)?; + match v.len() { + 8 => rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64), + 4 => Some(i32::from_le_bytes(v.try_into().ok()?) as f64), + _ => None, + } +} + +struct R<'a> { + report: &'a mut Report, + mobs: HashMap, + essence: HashMap, + doc: Document, + /// (file mob id, slot id, master key) → source index + sources: HashMap<(MobId, u32, String), usize>, + /// Composition mobs read as nested compositions: their index in `doc.nested`. + nests: HashMap, + /// The compositions being read, outermost first. + open: Vec, +} + +struct NestClip<'a> { + mob_id: MobId, + slot_id: u32, + rate: (i64, i64), + kind: CKind, + name: String, + mob: &'a Obj, +} + +/// Compositions nested deeper than this are read as gaps. +const MAX_NESTING: usize = 16; + +pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result { + let root = store::read(bytes).map_err(|e| Error::Parse { format: "AAF", message: e })?; + let header = root.strong(pid::ROOT_HEADER).ok_or_else(|| Error::Parse { format: "AAF", message: "no header object".into() })?; + let content = header.strong(pid::CONTENT).ok_or_else(|| Error::Parse { format: "AAF", message: "no content storage".into() })?; + let mut mobs = HashMap::new(); + for m in content.objs(pid::MOBS) { + if let Some(id) = m.mob_id(pid::MOB_ID) { + mobs.insert(id, m); + } + } + let mut essence = HashMap::new(); + for e in content.objs(pid::ESSENCE_DATA) { + if let (Some(id), Some(d)) = (e.mob_id(pid::ESSENCE_MOB_ID), e.stream(pid::ESSENCE_STREAM)) { + essence.insert(id, d); + } + } + let mut r = R { report, mobs, essence, doc: Document::default(), sources: HashMap::new(), nests: HashMap::new(), open: Vec::new() }; + // Top-level compositions: those no other composition uses, among the ones tagged top-level + // when any are. (Premiere Pro tags a nested sequence's composition top-level too; it is still + // only a nested sequence.) Compositions that only use each other in a circle all count. The + // label older FilmCraft versions wrote for top-level counts as top-level too. + 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 is_top = |c: &&Obj| c.auid(pid::USAGE_CODE).is_some_and(|u| u == def::USAGE_TOP_LEVEL || u == def::USAGE_TOP_LEVEL_OLD); + let tagged_top: Vec<&Obj> = comps.iter().copied().filter(is_top).collect(); + let pool = if tagged_top.is_empty() { comps } else { tagged_top }; + let unused: Vec<&Obj> = pool.iter().copied().filter(|c| c.mob_id(pid::MOB_ID).is_none_or(|id| !referenced.contains(&id))).collect(); + let top = if unused.is_empty() { pool } else { unused }; + for c in top { + r.open.extend(c.mob_id(pid::MOB_ID)); + let comp = r.composition(c); + r.open.clear(); + r.doc.compositions.push(comp); + } + if r.doc.compositions.is_empty() { + return Err(Error::Empty); + } + Ok(r.doc) +} + +fn collect_refs(o: &Obj, out: &mut HashSet) { + if o.class == cls::SOURCE_CLIP + && let Some(id) = o.mob_id(pid::SOURCE_ID) + { + out.insert(id); + } + for (_, v) in &o.props { + match v { + Value::Strong(c) => collect_refs(c, out), + Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| collect_refs(c, out)), + _ => {} + } + } +} + +fn rate_of(o: &Obj, p: u16) -> Option<(i64, i64)> { + o.rational(p).filter(|r| r.0 > 0 && r.1 > 0) +} + +impl R<'_> { + fn composition(&mut self, c: &Obj) -> Composition { + let name = c.string(pid::MOB_NAME).unwrap_or_else(|| "Sequence".into()); + let slots = c.objs(pid::SLOTS); + let pic_rate = slots + .iter() + .filter(|s| s.class == cls::TIMELINE_MOB_SLOT) + .find(|s| s.strong(pid::SEGMENT).is_some_and(|g| matches!(data_kind(g), DataKind::Picture | DataKind::Timecode))) + .and_then(|s| rate_of(s, pid::EDIT_RATE)) + .or_else(|| slots.iter().find_map(|s| rate_of(s, pid::EDIT_RATE))) + .unwrap_or((25, 1)); + let rate = FrameRate::new(pic_rate.0, pic_rate.1); + let tags = tagged_values(c, pid::MOB_USER_COMMENTS); + let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone()); + let (mut width, mut height) = tag("FilmCraft Frame Size") + .and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?)))) + .unwrap_or((0, 0)); + let mut sample_rate = tag("FilmCraft Audio Sample Rate").and_then(|s| s.parse().ok()).unwrap_or(0u32); + let mut comp = Composition { + name, + rate, + sample_rate: 48_000, + width: 1920, + height: 1080, + start_tc: 0, + drop: false, + tracks: Vec::new(), + markers: Vec::new(), + }; + let (mut nv, mut na) = (0, 0); + for s in slots { + let Some(seg) = s.strong(pid::SEGMENT) else { continue }; + if s.class == cls::EVENT_MOB_SLOT { + let er = rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(pic_rate); + comp.markers.extend(markers_of(seg, er)); + continue; + } + let er = rate_of(s, pid::EDIT_RATE).unwrap_or(pic_rate); + let origin = s.i64(pid::ORIGIN).unwrap_or(0); + match data_kind(seg) { + DataKind::Timecode => { + if let Some(tc) = find_class(seg, cls::TIMECODE) { + comp.start_tc = tc.i64(pid::TC_START).unwrap_or(0); + comp.drop = tc.u8(pid::TC_DROP).unwrap_or(0) != 0; + } + } + kind @ (DataKind::Picture | DataKind::Sound) => { + let ckind = if kind == DataKind::Picture { CKind::Picture } else { CKind::Sound }; + if ckind == CKind::Sound && sample_rate == 0 && er.1 == 1 && er.0 >= 8000 { + sample_rate = er.0 as u32; + } + // Premiere Pro writes every video track into one slot: a nested scope with a + // segment per track, the lowest track first. (A scope whose last segment + // refers to the others is an effect over layers: one track, as before.) + let layers: Vec<&Obj> = if ckind == CKind::Picture && seg.class == cls::NESTED_SCOPE && !refers_to_scope(seg, 0) { + seg.objs(pid::NESTED_SLOTS).iter().collect() + } else { + Vec::new() + }; + let layered = !layers.is_empty(); + let whole = units_to_ticks(len_of(seg), er.0, er.1); + for layer in if layered { layers } else { vec![seg] } { + let mut items = self.segment(layer, er, ckind, 0); + if layered { + fit(&mut items, whole); + } + 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) => self.doc.sources.get(c.source).map(|s| s.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 && !layered { number } else { n }, channels, items }); + } + } + DataKind::Other => self.report.info("a slot of an unsupported data kind was skipped"), + } + } + if width == 0 { + // no size in the file: that of the first picture this composition shows, else of any + let own = comp.tracks.iter().filter(|t| t.kind == CKind::Picture).flat_map(|t| &t.items).find_map(|i| match i { + CItem::Clip(c) => self.doc.sources.get(c.source).filter(|s| s.kind == CKind::Picture && s.width > 0 && s.nested.is_none()), + _ => None, + }); + let pic = own.or_else(|| self.doc.sources.iter().find(|s| s.kind == CKind::Picture && s.width > 0)); + (width, height) = pic.map_or((1920, 1080), |p| (p.width, p.height)); + } + comp.width = width; + comp.height = height; + if sample_rate > 0 { + comp.sample_rate = sample_rate; + } + comp + } + + /// Items of a segment (a sequence or a single component). + fn segment(&mut self, seg: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec { + if depth > 32 { + self.report.warn("segments nested too deeply were skipped"); + return vec![CItem::Filler(units_to_ticks(len_of(seg), rate.0, rate.1))]; + } + if seg.class == cls::SEQUENCE { + let mut out = Vec::new(); + for c in seg.objs(pid::COMPONENTS) { + out.extend(self.component(c, rate, kind, depth + 1)); + } + return out; + } + self.component(seg, rate, kind, depth + 1) + } + + fn component(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec { + let len = units_to_ticks(len_of(c), rate.0, rate.1); + match c.class { + x if x == cls::FILLER => vec![CItem::Filler(len)], + x if x == cls::SOURCE_CLIP => vec![self.clip(c, rate, kind, None)], + x if x == cls::SEQUENCE => self.segment(c, rate, kind, depth), + x if x == cls::TRANSITION => { + let cut = units_to_ticks(c.i64(pid::CUT_POINT).unwrap_or(0), rate.0, rate.1); + let og = c.strong(pid::OPERATION_GROUP); + let effect = og + .and_then(|o| tagged_values(o, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "FilmCraft Effect").map(|(_, v)| v)) + .unwrap_or_else(|| { + let op = og.and_then(|o| o.weak_key(pid::OPERATION)).and_then(|k| ::try_from(k).ok()); + match (kind, op) { + (CKind::Sound, _) => "constant_power".into(), + (_, Some(o)) if o == def::VIDEO_FADE_TO_BLACK => "dip_to_black".into(), + (_, Some(o)) if o == def::SMPTE_VIDEO_WIPE => "wipe".into(), + _ => "cross_dissolve".into(), + } + }); + vec![CItem::Transition(CTransition { len, cut: cut.clamp(Tick::ZERO, len), effect })] + } + x if x == cls::OPERATION_GROUP => { + let op = c.weak_key(pid::OPERATION).and_then(|k| ::try_from(k).ok()); + let inputs = c.objs(pid::INPUT_SEGMENTS); + let Some(input) = inputs.first() else { + self.report.info("an effect without input was imported as a gap"); + return vec![CItem::Filler(len)]; + }; + let gain = if op == Some(def::MONO_AUDIO_GAIN) { + self.gain(c, len, rate) + } else { + self.report.warn("effects other than audio gain are not imported (their first input is used)"); + None + }; + let mut items = self.segment(input, rate, kind, depth); + // the operation's length rules + fit(&mut items, len); + if let Some(g) = gain { + match items.as_mut_slice() { + [CItem::Clip(cl)] => cl.gain = Some(g), + _ => self.report.warn("audio gain around several clips was not imported"), + } + } + items + } + x if x == cls::SELECTOR => match c.strong(pid::SELECTED) { + Some(s) => { + let mut items = self.segment(s, rate, kind, depth); + fit(&mut items, len); + items + } + None => vec![CItem::Filler(len)], + }, + x if x == cls::NESTED_SCOPE => match c.objs(pid::NESTED_SLOTS).last() { + Some(s) => { + let mut items = self.segment(s, rate, kind, depth); + fit(&mut items, len); + items + } + None => vec![CItem::Filler(len)], + }, + _ => { + self.report.info("an unsupported component was imported as a gap"); + vec![CItem::Filler(len)] + } + } + } + + fn gain(&mut self, og: &Obj, len: Tick, _rate: (i64, i64)) -> Option { + let p = og + .objs(pid::PARAMETERS) + .iter() + .find(|p| p.auid(pid::PARAMETER_DEFINITION) == Some(def::PARAM_AMPLITUDE)) + .or_else(|| og.objs(pid::PARAMETERS).first())?; + if p.class == cls::CONSTANT_VALUE { + return p.data(pid::CONSTANT_VALUE).and_then(indirect_f64).map(Gain::Constant); + } + if p.class == cls::VARYING_VALUE { + let linear = p.weak_key(pid::INTERPOLATION).and_then(|k| ::try_from(k).ok()) != Some(def::INTERP_CONSTANT); + let points: Vec<(Tick, f64)> = p + .objs(pid::POINT_LIST) + .iter() + .filter_map(|cp| { + let (n, d) = cp.rational(pid::CP_TIME).filter(|r| r.1 != 0)?; + let v = cp.data(pid::CP_VALUE).and_then(indirect_f64)?; + Some((Tick((len.0 as i128 * n as i128 / d as i128).clamp(-(1i128 << 56), 1i128 << 56) as i64), v)) + }) + .collect(); + return (!points.is_empty()).then_some(Gain::Varying { linear, points }); + } + None + } + + fn clip(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, master_key: Option) -> CItem { + let len = units_to_ticks(len_of(c), rate.0, rate.1); + let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1); + let name = tagged_values(c, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "Clip Name").map(|(_, v)| v).unwrap_or_default(); + let (Some(id), Some(slot)) = (c.mob_id(pid::SOURCE_ID), c.u32(pid::SOURCE_MOB_SLOT_ID)) else { return CItem::Filler(len) }; + if id == [0; 32] { + return CItem::Filler(len); + } + if let Some(mob) = self.mobs.get(&id).copied().filter(|m| m.class == cls::COMPOSITION_MOB) { + return self.nest_clip(c, NestClip { mob, mob_id: id, slot_id: slot, rate, kind, name }); + } + 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), + } + } + + /// A source clip that points at a composition: a nested sequence (Premiere Pro writes its + /// nested sequences this way). The composition is read once, however many clips use it. + fn nest_clip(&mut self, c: &Obj, clip: NestClip<'_>) -> CItem { + let NestClip { mob, mob_id: id, slot_id, rate, kind, name } = clip; + let len = units_to_ticks(len_of(c), rate.0, rate.1); + let index = match self.nests.get(&id) { + Some(&i) => i, + None => { + if self.open.contains(&id) || self.open.len() >= MAX_NESTING { + self.report.warn("a composition nested in itself or nested too deeply was imported as a gap"); + return CItem::Filler(len); + } + self.open.push(id); + let comp = self.composition(mob); + self.open.pop(); + let i = self.doc.nested.len(); + self.doc.nested.push(comp); + self.nests.insert(id, i); + i + } + }; + // the start counts edit units of the slot the clip points at + let slot_rate = + mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot_id)).and_then(|s| rate_of(s, pid::EDIT_RATE)).unwrap_or(rate); + let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), slot_rate.0, slot_rate.1); + let skey = (id, if kind == CKind::Picture { 0 } else { 1 }, "composition".to_string()); + let source = match self.sources.get(&skey) { + Some(&i) => i, + None => { + let Some(comp) = self.doc.nested.get(index) else { return CItem::Filler(len) }; + let length = comp + .tracks + .iter() + .map(|t| { + Tick(t.items.iter().fold(0i64, |a, i| { + if matches!(i, CItem::Transition(_)) { a.saturating_sub(i.len().0) } else { a.saturating_add(i.len().0) } + })) + }) + .max() + .unwrap_or(Tick::ZERO); + let channels = if kind == CKind::Sound { 2 } else { 0 }; + let s = Source { + key: format!("composition:{}", hex(&id)), + name: comp.name.clone(), + kind, + path: None, + channel: None, + channels, + file_channels: channels, + width: comp.width, + height: comp.height, + frame_rate: comp.rate, + sample_rate: comp.sample_rate, + bits: 16, + length, + offset: Tick::ZERO, + start_tc: None, + tc_rate: comp.rate, + embedded: None, + markers: Vec::new(), + nested: Some(index), + }; + let i = self.doc.sources.len(); + self.doc.sources.push(s); + self.sources.insert(skey, i); + i + } + }; + CItem::Clip(CClip { len, source, start, gain: None, name }) + } + + /// Follow a source reference to a file source mob: (source index, file time). + fn resolve(&mut self, id: MobId, slot_id: u32, start: Tick, kind: CKind, master_key: Option, depth: usize) -> Option<(usize, Tick)> { + if depth > 16 { + return None; + } + let Some(mob) = self.mobs.get(&id).copied() else { + self.report.warn("a clip references a mob that is not in the file (imported as a gap)"); + return None; + }; + let slot = mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot_id))?; + let rate = rate_of(slot, pid::EDIT_RATE).unwrap_or((25, 1)); + let seg = slot.strong(pid::SEGMENT)?; + if mob.class == cls::COMPOSITION_MOB { + self.report.warn("nested compositions are not supported (imported as gaps)"); + return None; + } + if mob.class == cls::MASTER_MOB { + let key = master_key.unwrap_or_else(|| hex(&id)); + let clip = first_clip(seg)?; + let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1); + let next = clip.mob_id(pid::SOURCE_ID)?; + let next_slot = clip.u32(pid::SOURCE_MOB_SLOT_ID)?; + if next == [0; 32] { + return None; + } + let markers = mob + .objs(pid::SLOTS) + .iter() + .filter(|s| s.class == cls::EVENT_MOB_SLOT) + .flat_map(|s| s.strong(pid::SEGMENT).map(|g| markers_of(g, rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(rate))).unwrap_or_default()) + .collect::>(); + let r = self.resolve(next, next_slot, start + inner, kind, Some(key), depth + 1)?; + let src = &mut self.doc.sources[r.0]; + if src.markers.is_empty() { + src.markers = markers; + } + if src.name.is_empty() + && let Some(n) = mob.string(pid::MOB_NAME) + { + src.name = n; + } + return Some(r); + } + // a source mob: file (has a file descriptor) or physical (tape / import) + let desc = mob.strong(pid::ESSENCE_DESCRIPTION); + let is_file = desc.is_some_and(|d| d.get(pid::SAMPLE_RATE).is_some() || d.get(pid::LOCATOR).is_some() || self.essence.contains_key(&id)); + if !is_file { + return None; + } + let key = master_key.unwrap_or_else(|| hex(&id)); + let skey = (id, slot_id, key.clone()); + if let Some(&i) = self.sources.get(&skey) { + return Some((i, start)); + } + let d = desc?; + let seg_kind = match data_kind(seg) { + DataKind::Picture => CKind::Picture, + DataKind::Sound => CKind::Sound, + _ => kind, + }; + let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| craft_timeline::path::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.saturating_mul(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(), + nested: None, + }; + 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::pcm::parse_wav(data) { + s.embedded = Some(pcm); + s.file_channels = ch as u32; + s.channels = if s.channel.is_some() { 1 } else { ch as u32 }; + s.sample_rate = sr; + s.bits = bits; + } + } + } + if s.kind == CKind::Picture && s.width == 0 { + s.width = 1920; + s.height = 1080; + } + let i = self.doc.sources.len(); + self.doc.sources.push(s); + self.sources.insert(skey, i); + Some((i, start)) + } +} + +/// Drop `t` from the front of a track's items (slot origin). +fn trim_front(items: &mut Vec, mut t: Tick) { + while t > Tick::ZERO && !items.is_empty() { + let l = items[0].len(); + if matches!(items[0], CItem::Transition(_)) { + items.remove(0); + continue; + } + if l <= t { + items.remove(0); + t -= l; + } else { + match &mut items[0] { + CItem::Filler(x) => *x -= t, + CItem::Clip(c) => { + c.len -= t; + c.start += t; + } + CItem::Transition(_) => {} + } + t = Tick::ZERO; + } + } +} + +/// Make the items exactly `len` long (an operation group / selector is as long as itself). +fn fit(items: &mut Vec, len: Tick) { + let total = Tick( + items.iter().fold(0i64, |a, i| if matches!(i, CItem::Transition(_)) { a.saturating_sub(i.len().0) } else { a.saturating_add(i.len().0) }), + ); + if total == len || items.is_empty() { + if items.is_empty() { + items.push(CItem::Filler(len)); + } + return; + } + if total < len { + items.push(CItem::Filler(len - total)); + return; + } + let mut excess = total - len; + while excess > Tick::ZERO { + match items.last_mut() { + Some(CItem::Filler(x)) | Some(CItem::Clip(CClip { len: x, .. })) if *x > excess => { + *x -= excess; + excess = Tick::ZERO; + } + Some(CItem::Transition(_)) => { + items.pop(); + } + Some(other) => { + excess -= other.len(); + items.pop(); + } + None => break, + } + } +} + +/// Whether anything inside `o` is a scope reference (a nested scope used as an effect's layers). +fn refers_to_scope(o: &Obj, depth: usize) -> bool { + if o.class == cls::SCOPE_REFERENCE { + return true; + } + if depth > 32 { + return false; + } + o.props.iter().any(|(_, v)| match v { + Value::Strong(c) => refers_to_scope(c, depth + 1), + Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().any(|c| refers_to_scope(c, depth + 1)), + _ => false, + }) +} + +fn first_clip(seg: &Obj) -> Option<&Obj> { + if seg.class == cls::SOURCE_CLIP { + return Some(seg); + } + if seg.class == cls::SEQUENCE { + return seg.objs(pid::COMPONENTS).iter().find_map(first_clip); + } + // essence group (choices 0x0501) / operation group inputs + seg.objs(0x0501).iter().find_map(first_clip).or_else(|| seg.objs(pid::INPUT_SEGMENTS).iter().find_map(first_clip)) +} + +fn find_class(seg: &Obj, class: Auid) -> Option<&Obj> { + if seg.class == class { + return Some(seg); + } + seg.objs(pid::COMPONENTS).iter().find_map(|c| find_class(c, class)) +} + +fn markers_of(seg: &Obj, rate: (i64, i64)) -> Vec { + let events: Vec<&Obj> = if seg.class == cls::SEQUENCE { seg.objs(pid::COMPONENTS).iter().collect() } else { vec![seg] }; + events + .into_iter() + .filter(|e| e.class == cls::COMMENT_MARKER || e.class == cls::DESCRIPTIVE_MARKER || e.get(pid::POSITION).is_some()) + .map(|e| { + let tags = tagged_values(e, pid::COMPONENT_USER_COMMENTS); + let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone()); + let comment = e.string(pid::COMMENT).unwrap_or_default(); + let start = units_to_ticks(e.i64(pid::POSITION).unwrap_or(0), rate.0, rate.1); + CMarker { + start, + duration: units_to_ticks(e.i64(pid::LENGTH).unwrap_or(0).max(0), rate.0, rate.1), + name: tag("Name").unwrap_or_else(|| comment.clone()), + comment: tag("Comment").unwrap_or_else(|| if tags.is_empty() { String::new() } else { comment.clone() }), + color: tag("Color"), + } + }) + .collect() +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} diff --git a/crates/aaf/src/aaf/store.rs b/crates/aaf/src/aaf/store.rs new file mode 100644 index 00000000..46ab95da --- /dev/null +++ b/crates/aaf/src/aaf/store.rs @@ -0,0 +1,538 @@ +//! 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 craft_cfb::{CompoundFile, EntryKind, NodeId, Version, Writer}; + +pub(crate) type Auid = [u8; 16]; + +pub(crate) const SF_DATA: u16 = 0x82; +pub(crate) const SF_DATA_STREAM: u16 = 0x42; +pub(crate) const SF_STRONG: u16 = 0x22; +pub(crate) const SF_STRONG_VECTOR: u16 = 0x32; +pub(crate) const SF_STRONG_SET: u16 = 0x3A; +pub(crate) const SF_WEAK: u16 = 0x02; +pub(crate) const SF_WEAK_VECTOR: u16 = 0x12; +pub(crate) const SF_WEAK_SET: u16 = 0x1A; + +const BYTE_ORDER_LE: u8 = 0x4C; +const FORMAT_VERSION: u8 = 0x20; + +/// A property value. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Value { + Data(Vec), + Strong(Box), + StrongVec(Vec), + /// Objects keyed by the data property `key_pid` of each. + StrongSet(Vec, u16), + /// (path of pids from the root to the target collection, key pid, key) + Weak(Vec, u16, Vec), + WeakVec(Vec, u16, Vec>), + Stream(Vec), +} + +/// An AAF object: its class and properties. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Obj { + pub class: Auid, + pub props: Vec<(u16, Value)>, +} + +impl Obj { + pub fn new(class: Auid) -> Obj { + Obj { class, props: Vec::new() } + } + pub fn set(&mut self, pid: u16, v: Value) -> &mut Obj { + match self.props.iter_mut().find(|(p, _)| *p == pid) { + Some(slot) => slot.1 = v, + None => self.props.push((pid, v)), + } + self + } + pub fn with(mut self, pid: u16, v: Value) -> Obj { + self.set(pid, v); + self + } + pub fn data_prop(mut self, pid: u16, d: Vec) -> Obj { + self.set(pid, Value::Data(d)); + self + } + pub fn get(&self, pid: u16) -> Option<&Value> { + self.props.iter().find(|(p, _)| *p == pid).map(|(_, v)| v) + } + pub fn data(&self, pid: u16) -> Option<&[u8]> { + match self.get(pid)? { + Value::Data(d) => Some(d), + _ => None, + } + } + pub fn strong(&self, pid: u16) -> Option<&Obj> { + match self.get(pid)? { + Value::Strong(o) => Some(o), + _ => None, + } + } + /// Elements of a strong vector or set (empty when absent). + pub fn objs(&self, pid: u16) -> &[Obj] { + match self.get(pid) { + Some(Value::StrongVec(v)) | Some(Value::StrongSet(v, _)) => v, + _ => &[], + } + } + pub fn weak_key(&self, pid: u16) -> Option<&[u8]> { + match self.get(pid)? { + Value::Weak(_, _, k) => Some(k), + _ => None, + } + } + pub fn stream(&self, pid: u16) -> Option<&[u8]> { + match self.get(pid)? { + Value::Stream(d) => Some(d), + _ => None, + } + } + pub fn u8(&self, pid: u16) -> Option { + self.data(pid).and_then(|d| d.first().copied()) + } + pub fn u16(&self, pid: u16) -> Option { + self.data(pid).filter(|d| d.len() >= 2).map(|d| u16::from_le_bytes([d[0], d[1]])) + } + pub fn u32(&self, pid: u16) -> Option { + self.data(pid).filter(|d| d.len() >= 4).map(|d| u32::from_le_bytes([d[0], d[1], d[2], d[3]])) + } + pub fn i64(&self, pid: u16) -> Option { + let d = self.data(pid)?; + match d.len() { + 8.. => Some(i64::from_le_bytes(d[..8].try_into().ok()?)), + 4..=7 => Some(i32::from_le_bytes(d[..4].try_into().ok()?) as i64), + _ => None, + } + } + pub fn rational(&self, pid: u16) -> Option<(i64, i64)> { + self.data(pid).and_then(rational_of) + } + pub fn string(&self, pid: u16) -> Option { + self.data(pid).map(utf16_of) + } + pub fn auid(&self, pid: u16) -> Option { + self.data(pid).and_then(|d| d.get(..16)).and_then(|d| d.try_into().ok()) + } + pub fn mob_id(&self, pid: u16) -> Option<[u8; 32]> { + self.data(pid).and_then(|d| d.get(..32)).and_then(|d| d.try_into().ok()) + } +} + +pub(crate) fn rational_of(d: &[u8]) -> Option<(i64, i64)> { + (d.len() >= 8).then(|| (i32::from_le_bytes([d[0], d[1], d[2], d[3]]) as i64, i32::from_le_bytes([d[4], d[5], d[6], d[7]]) as i64)) +} + +pub(crate) fn rational(num: i64, den: i64) -> Vec { + let mut v = (num.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes().to_vec(); + v.extend_from_slice(&(den.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes()); + v +} + +/// UTF-16LE with a terminating NUL. +pub(crate) fn utf16z(s: &str) -> Vec { + let mut v: Vec = s.encode_utf16().flat_map(u16::to_le_bytes).collect(); + v.extend_from_slice(&[0, 0]); + v +} + +pub(crate) fn utf16_of(d: &[u8]) -> String { + let u: Vec = d.as_chunks::<2>().0.iter().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", + 0x0003 => "ClassDefinitions", + 0x0004 => "TypeDefinitions", + 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", + } +} + +/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it +/// from: AUIDs (definitions) are 16 bytes, MobIDs 32. +fn set_key_size(key_pid: u16) -> usize { + match key_pid { + 0x0005 | 0x1B01 => 16, + 0x4401 | 0x2701 => 32, + _ => 0, + } +} + +fn storage_name(pid: u16) -> String { + let suffix = format!("-{pid:x}"); + let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect(); + format!("{name}{suffix}") +} + +/// Errors while reading the object tree. +fn bad(s: impl Into) -> String { + s.into() +} + +// --------------------------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------------------------- + +struct W { + cfb: Writer, + paths: Vec>, +} + +/// Serialise `root` (the Root object: MetaDictionary 0x0001, Header 0x0002) into a compound file. +pub(crate) fn write(root: &Obj, version: Version) -> Result, String> { + let mut w = W { cfb: Writer::new(version), paths: Vec::new() }; + w.obj(Writer::ROOT, root)?; + let mut rp = vec![BYTE_ORDER_LE]; + rp.extend_from_slice(&(w.paths.len() as u16).to_le_bytes()); + let n: usize = w.paths.iter().map(|p| p.len() + 1).sum(); + rp.extend_from_slice(&(n as u32).to_le_bytes()); + for p in &w.paths { + for pid in p { + rp.extend_from_slice(&pid.to_le_bytes()); + } + rp.extend_from_slice(&0u16.to_le_bytes()); + } + w.cfb.stream(Writer::ROOT, "referenced properties", rp).map_err(|e| e.to_string())?; + Ok(w.cfb.finish()) +} + +impl W { + fn tag(&mut self, path: &[u16]) -> u16 { + match self.paths.iter().position(|p| p == path) { + Some(i) => i as u16, + None => { + self.paths.push(path.to_vec()); + (self.paths.len() - 1) as u16 + } + } + } + + fn obj(&mut self, node: NodeId, o: &Obj) -> Result<(), String> { + self.cfb.set_clsid(node, o.class); + let mut entries: Vec<(u16, u16, Vec)> = Vec::new(); + for (pid, v) in &o.props { + let pid = *pid; + let (sf, value) = match v { + Value::Data(d) => (SF_DATA, d.clone()), + Value::Strong(child) => { + let name = storage_name(pid); + let s = self.cfb.storage(node, &name, child.class).map_err(|e| e.to_string())?; + self.obj(s, child)?; + (SF_STRONG, utf16z(&name)) + } + Value::StrongVec(items) | Value::StrongSet(items, _) => { + let name = storage_name(pid); + let mut index = Vec::new(); + index.extend_from_slice(&(items.len() as u32).to_le_bytes()); + index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key + index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key + let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None }; + let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0); + if let Some(k) = set_key { + index.extend_from_slice(&k.to_le_bytes()); + index.push(key_size as u8); + } + for (i, item) in items.iter().enumerate() { + let s = self.cfb.storage(node, &format!("{name}{{{i:x}}}"), item.class).map_err(|e| e.to_string())?; + self.obj(s, item)?; + index.extend_from_slice(&(i as u32).to_le_bytes()); + if let Some(k) = set_key { + index.extend_from_slice(&1u32.to_le_bytes()); + let mut key = item.data(k).unwrap_or(&[]).to_vec(); + key.resize(key_size, 0); + index.extend_from_slice(&key); + } + } + self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?; + (if set_key.is_some() { SF_STRONG_SET } else { SF_STRONG_VECTOR }, utf16z(&name)) + } + Value::Weak(path, key_pid, key) => { + let mut d = self.tag(path).to_le_bytes().to_vec(); + d.extend_from_slice(&key_pid.to_le_bytes()); + d.push(key.len() as u8); + d.extend_from_slice(key); + (SF_WEAK, d) + } + Value::WeakVec(path, key_pid, keys) => { + let name = storage_name(pid); + let tag = self.tag(path); + let size = keys.first().map_or(0, Vec::len); + let mut index = (keys.len() as u32).to_le_bytes().to_vec(); + index.extend_from_slice(&tag.to_le_bytes()); + index.extend_from_slice(&key_pid.to_le_bytes()); + index.push(size as u8); + for k in keys { + let mut k = k.clone(); + k.resize(size, 0); + index.extend_from_slice(&k); + } + self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?; + (SF_WEAK_VECTOR, utf16z(&name)) + } + Value::Stream(data) => { + let name = storage_name(pid); + self.cfb.stream(node, &name, data.clone()).map_err(|e| e.to_string())?; + let mut d = vec![BYTE_ORDER_LE]; + d.extend_from_slice(&utf16z(&name)); + (SF_DATA_STREAM, d) + } + }; + if value.len() > u16::MAX as usize { + return Err(format!("property {pid:#06x} is {} bytes (the limit is 65535)", value.len())); + } + entries.push((pid, sf, value)); + } + let mut props = vec![BYTE_ORDER_LE, FORMAT_VERSION]; + props.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (pid, sf, v) in &entries { + props.extend_from_slice(&pid.to_le_bytes()); + props.extend_from_slice(&sf.to_le_bytes()); + props.extend_from_slice(&(v.len() as u16).to_le_bytes()); + } + for (_, _, v) in &entries { + props.extend_from_slice(v); + } + self.cfb.stream(node, "properties", props).map_err(|e| e.to_string())?; + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------------------------- + +struct R<'a> { + cf: CompoundFile<'a>, + paths: Vec>, + objects: usize, + materialized_stream_bytes: usize, + /// Storages already read: a storage is owned by one parent, so a second strong reference to it + /// (which would rebuild its whole subtree again) is refused. + owned: std::collections::HashSet, + /// Bytes of weak-reference target paths copied into the tree so far. + path_bytes: usize, +} + +const MAX_DEPTH: usize = 200; +const MAX_OBJECTS: usize = 2_000_000; +const MAX_MATERIALIZED_STREAM_BYTES: usize = 256 << 20; +/// Each weak reference carries its own copy of its target path (real paths are a handful of +/// property ids); the copies together may not exceed this. +const MAX_PATH_BYTES: usize = 64 << 20; + +/// Parse the object tree of an AAF compound file (the root object). +pub(crate) fn read(bytes: &[u8]) -> Result { + let cf = CompoundFile::open(bytes).map_err(|e| e.to_string())?; + let mut paths = Vec::new(); + if let Ok(rp) = cf.read_path("referenced properties") + && rp.len() >= 7 + { + let count = u16::from_le_bytes([rp[1], rp[2]]) as usize; + let mut cur = Vec::new(); + for c in rp[7..].as_chunks::<2>().0 { + 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, materialized_stream_bytes: 0, owned: Default::default(), path_bytes: 0 }; + r.obj(0, 0) +} + +fn rd_u16(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn rd_u32(b: &[u8], at: usize) -> Option { + b.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +impl R<'_> { + fn child(&self, parent: usize, name: &str) -> Result { + self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\""))) + } + + fn stream(&mut self, parent: usize, name: &str) -> Result, String> { + let id = self.child(parent, name)?; + let data = self.cf.read(id).map_err(|e| e.to_string())?; + self.materialized_stream_bytes = self + .materialized_stream_bytes + .checked_add(data.len()) + .filter(|&n| n <= MAX_MATERIALIZED_STREAM_BYTES) + .ok_or_else(|| bad("AAF materialized stream data exceeds the memory limit"))?; + Ok(data) + } + + /// A copy of weak-reference path `tag`, counted against [`MAX_PATH_BYTES`]. + fn path(&mut self, tag: u16) -> Result, String> { + let path = self.paths.get(tag as usize).cloned().unwrap_or_default(); + self.path_bytes = self.path_bytes.saturating_add(path.len() * 2); + if self.path_bytes > MAX_PATH_BYTES { + return Err(bad("weak reference paths exceed the memory limit")); + } + Ok(path) + } + + fn obj(&mut self, entry: usize, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(bad("objects nested too deeply")); + } + if !self.owned.insert(entry) { + return Err(bad("a storage is owned by more than one reference")); + } + 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")); + }; + if o.props.iter().any(|(previous, _)| *previous == pid) { + return Err(bad("duplicate property id")); + } + 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.path(tag)?, 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; + if count > 0 && size == 0 { + return Err(bad("zero-size weak collection key")); + } + // every key must lie in the index, so its length (not the count) bounds the loop + if (count as usize).checked_mul(size).and_then(|n| n.checked_add(9)).is_none_or(|end| end > idx.len()) { + return Err(bad("short weak collection index")); + } + 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.path(tag)?, key_pid, keys) + } + SF_DATA_STREAM => { + let name = utf16_of(v.get(1..).unwrap_or(&[])); + Value::Stream(self.stream(entry, &name)?) + } + _ => Value::Data(v.to_vec()), + }; + o.props.push((pid, value)); + } + Ok(o) + } +} diff --git a/crates/aaf/src/aaf/tests.rs b/crates/aaf/src/aaf/tests.rs new file mode 100644 index 00000000..2773fda6 --- /dev/null +++ b/crates/aaf/src/aaf/tests.rs @@ -0,0 +1,467 @@ +//! 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 the compound file reader. + +use std::collections::HashSet; + +use craft_timeline::time::FrameRate; + +use super::ids::{self, cls, ddef, def, path, pid}; +use super::store::{self, Obj, Value}; +use super::*; +use crate::model::{CItem, CKind}; +use crate::testdoc::{IDENTITY, clip, composition, picture_and_sound_cut, source, track}; + +fn document() -> Document { + // two 48-frame clips from source frames 10 and 200, a 12-frame transition + picture_and_sound_cut(FrameRate::FPS_24, 48, 12, (10, 200)) +} + +fn written(doc: &Document, small_sectors: bool) -> Vec { + let mut report = Report::default(); + let bytes = write(doc, &WriteOptions { identity: IDENTITY, small_sectors }, &mut report).unwrap(); + assert!(!report.has_warnings(), "{report}"); + bytes +} + +fn read_clean(bytes: &[u8]) -> Document { + let mut report = Report::default(); + let doc = read(bytes, &mut report).unwrap(); + assert!(!report.has_warnings(), "{report}"); + doc +} + +fn walk<'a>(o: &'a Obj, out: &mut Vec<&'a Obj>) { + out.push(o); + for (_, v) in &o.props { + match v { + Value::Strong(c) => walk(c, out), + Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| walk(c, out)), + _ => {} + } + } +} + +fn keys(dict: &Obj, p: u16) -> HashSet> { + dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect() +} + +/// filmcraft#323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and +/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types +/// those readers already know. +#[test] +fn the_meta_dictionary_has_its_class_and_type_definition_sets() { + for small in [false, true] { + let bytes = written(&document(), small); + let cf = craft_cfb::CompoundFile::open(&bytes).unwrap(); + assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY); + for name in ["ClassDefinitions-3", "TypeDefinitions-4"] { + // u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size + let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(index.len(), 15, "{name}: an empty set"); + assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries"); + assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID"); + assert_eq!(index[14], 16, "{name}: 16-byte keys"); + } + let root = store::read(&bytes).unwrap(); + let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap(); + for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] { + assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}"); + } + } +} + +#[test] +fn written_files_have_the_required_structure() { + for small in [false, true] { + let bytes = written(&document(), small); + assert_written_file(&bytes, small); + } +} + +/// The top-level composition is tagged `Usage_TopLevel`, which the SMPTE Labels register gives as +/// urn:smpte:ul:060e2b34.04010101.0d010102.01010700, stored as the AUID +/// {0d010102-0101-0700-060e-2b3404010101}. Readers that look for it (LibAAF) find no sequence +/// without it. +#[test] +fn the_top_level_composition_has_the_registered_usage_code() { + let bytes = written(&document(), false); + let root = store::read(&bytes).unwrap(); + let content = root.strong(pid::ROOT_HEADER).unwrap().strong(pid::CONTENT).unwrap(); + let comp = content.objs(pid::MOBS).iter().find(|m| m.class == cls::COMPOSITION_MOB).unwrap(); + let usage = [0x02, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x00, 0x07, 0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01]; + assert_eq!(comp.data(pid::USAGE_CODE), Some(&usage[..])); +} + +/// Files from FilmCraft 0.16 and earlier tag the top-level composition with the unregistered label +/// 060e2b34.04010105.0d011201.02030000. They still import: the composition no other one uses is +/// the edit. +#[test] +fn files_with_the_old_usage_code_still_import() { + let bytes = written(&document(), false); + let mut root = store::read(&bytes).unwrap(); + let content = strong_mut(strong_mut(&mut root, pid::ROOT_HEADER), pid::CONTENT); + for mob in objs_mut(content, pid::MOBS).iter_mut().filter(|m| m.class == cls::COMPOSITION_MOB) { + mob.set(pid::USAGE_CODE, Value::Data(def::USAGE_TOP_LEVEL_OLD.to_vec())); + } + let old_file = store::write(&root, craft_cfb::Version::V4).unwrap(); + let got = read_clean(&old_file); + assert_eq!(got.compositions.len(), 1); + assert_eq!(got, read_clean(&bytes)); +} + +/// A file that holds one edit tagged with each label (a tool merged a composition from an older +/// file into a newer one) imports both edits. +#[test] +fn edits_tagged_with_either_usage_code_both_import() { + let bytes = written(&document(), false); + let mut root = store::read(&bytes).unwrap(); + let content = strong_mut(strong_mut(&mut root, pid::ROOT_HEADER), pid::CONTENT); + let mobs = objs_mut(content, pid::MOBS); + let mut old = mobs.iter().find(|m| m.class == cls::COMPOSITION_MOB).unwrap().clone(); + let mut id = old.mob_id(pid::MOB_ID).unwrap(); + id[31] ^= 0xFF; + old.set(pid::MOB_ID, Value::Data(id.to_vec())); + old.set(pid::MOB_NAME, Value::Data(store::utf16z("Old"))); + old.set(pid::USAGE_CODE, Value::Data(def::USAGE_TOP_LEVEL_OLD.to_vec())); + mobs.push(old); + let mixed = store::write(&root, craft_cfb::Version::V4).unwrap(); + let mut names: Vec = read_clean(&mixed).compositions.into_iter().map(|c| c.name).collect(); + names.sort(); + assert_eq!(names, ["Old", "S"]); +} + +fn assert_written_file(bytes: &[u8], small: bool) { + assert_compound_file(bytes, small); + let root = store::read(bytes).unwrap(); + assert!(root.strong(pid::ROOT_META_DICTIONARY).is_some()); + let header = root.strong(pid::ROOT_HEADER).unwrap(); + let content = assert_header(header); + let dict = header.strong(pid::DICTIONARY).unwrap(); + let definitions = Definitions { + data: keys(dict, pid::DATA_DEFINITIONS), + operations: keys(dict, pid::OPERATION_DEFINITIONS), + interpolation: keys(dict, pid::INTERPOLATION_DEFINITIONS), + containers: keys(dict, pid::CONTAINER_DEFINITIONS), + parameters: keys(dict, pid::PARAMETER_DEFINITIONS), + }; + let mobs = content.objs(pid::MOBS); + let comp = assert_mob_set(content, mobs); + for mob in mobs { + assert_mob(mob, mobs, &definitions); + } + assert_composition(comp); +} + +fn assert_compound_file(bytes: &[u8], small: bool) { + let cf = craft_cfb::CompoundFile::open(bytes).unwrap(); + assert_eq!(cf.version, if small { 3 } else { 4 }); + assert_eq!(cf.root().clsid, ids::ROOT); + let references = cf.read_path("referenced properties").unwrap(); + let pids: Vec = references[7..].as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + assert!(pids.windows(3).any(|w| w == path::DATA_DEFS)); + let header = cf.find("Header-2").unwrap(); + assert_eq!(cf.entries[header].clsid, cls::HEADER); + assert!(cf.read_path("Header-2/properties").unwrap().starts_with(&[0x4C, 0x20])); +} + +fn assert_header(header: &Obj) -> &Obj { + 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}"); + } + header.strong(pid::CONTENT).unwrap() +} + +struct Definitions { + data: HashSet>, + operations: HashSet>, + interpolation: HashSet>, + containers: HashSet>, + parameters: HashSet>, +} + +fn assert_mob_set<'a>(content: &Obj, mobs: &'a [Obj]) -> &'a Obj { + let mob_ids: Vec<[u8; 32]> = mobs.iter().map(|m| m.mob_id(pid::MOB_ID).unwrap()).collect(); + assert_eq!(mob_ids.iter().collect::>().len(), mob_ids.len(), "unique mob ids"); + assert!(matches!(content.get(pid::MOBS), Some(Value::StrongSet(_, k)) if *k == pid::MOB_ID)); + let compositions: Vec<&Obj> = mobs.iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect(); + assert_eq!(compositions.len(), 1); + assert_eq!(compositions[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"); + compositions[0] +} + +fn assert_mob(mob: &Obj, mobs: &[Obj], definitions: &Definitions) { + for req in [pid::MOB_ID, pid::MOB_NAME, pid::SLOTS, pid::MOB_LAST_MODIFIED, pid::MOB_CREATION_TIME] { + assert!(mob.get(req).is_some(), "Mob property {req:#06x}"); + } + assert_source_descriptor(mob, &definitions.containers); + for slot in mob.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(mob, &mut all); + for component in all { + assert_component(component, mobs, definitions); + } +} + +fn assert_source_descriptor(mob: &Obj, containers: &HashSet>) { + if mob.class != cls::SOURCE_MOB { + return; + } + let descriptor = mob.strong(pid::ESSENCE_DESCRIPTION).expect("SourceMob::EssenceDescription"); + if descriptor.class != cls::TAPE_DESCRIPTOR { + for req in [pid::SAMPLE_RATE, pid::FILE_LENGTH, pid::CONTAINER_FORMAT] { + assert!(descriptor.get(req).is_some(), "FileDescriptor property {req:#06x}"); + } + assert!(containers.contains(descriptor.weak_key(pid::CONTAINER_FORMAT).unwrap())); + let url = descriptor.objs(pid::LOCATOR)[0].string(pid::URL_STRING).unwrap(); + assert_eq!(url, "file:///m/a.mov"); + } + if descriptor.class == cls::PCM_DESCRIPTOR { + assert_eq!(descriptor.u32(pid::CHANNELS), Some(2)); + assert_eq!(descriptor.u32(pid::QUANTIZATION_BITS), Some(24)); + assert_eq!(descriptor.u16(pid::BLOCK_ALIGN), Some(6)); + } +} + +fn assert_component(o: &Obj, mobs: &[Obj], definitions: &Definitions) { + if is_component(o) { + let dd = o.weak_key(pid::DATA_DEFINITION).expect("Component::DataDefinition"); + assert!(definitions.data.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 { + assert_source_clip(o, mobs); + } else if o.class == cls::OPERATION_GROUP { + assert_operation_group(o, definitions); + } else if o.class == cls::SEQUENCE && o.get(pid::LENGTH).is_some() { + assert_sequence_length(o); + } +} + +fn is_component(o: &Obj) -> bool { + [cls::FILLER, cls::SOURCE_CLIP, cls::SEQUENCE, cls::TRANSITION, cls::OPERATION_GROUP, cls::TIMECODE, cls::COMMENT_MARKER].contains(&o.class) +} + +fn assert_source_clip(clip: &Obj, mobs: &[Obj]) { + let id = clip.mob_id(pid::SOURCE_ID).unwrap(); + let slot = clip.u32(pid::SOURCE_MOB_SLOT_ID).unwrap(); + assert!(clip.get(pid::START_TIME).is_some()); + if id != [0; 32] { + let target = mobs.iter().find(|m| m.mob_id(pid::MOB_ID) == Some(id)).expect("source clip target in the file"); + assert!(target.objs(pid::SLOTS).iter().any(|s| s.u32(pid::SLOT_ID) == Some(slot)), "target slot exists"); + } +} + +fn assert_operation_group(group: &Obj, definitions: &Definitions) { + assert!(definitions.operations.contains(group.weak_key(pid::OPERATION).unwrap())); + for parameter in group.objs(pid::PARAMETERS) { + assert!(definitions.parameters.contains(parameter.auid(pid::PARAMETER_DEFINITION).unwrap().as_slice())); + if parameter.class == cls::VARYING_VALUE { + assert!(definitions.interpolation.contains(parameter.weak_key(pid::INTERPOLATION).unwrap())); + assert!(!parameter.objs(pid::POINT_LIST).is_empty()); + } + } +} + +fn assert_sequence_length(sequence: &Obj) { + let components = sequence.objs(pid::COMPONENTS); + let sum = components + .iter() + .enumerate() + .map(|(i, component)| { + let len = component.i64(pid::LENGTH).unwrap(); + if component.class == cls::TRANSITION { + assert!(i > 0 && i + 1 < components.len(), "transition at a sequence end"); + assert!(components[i - 1].class != cls::TRANSITION && components[i + 1].class != cls::TRANSITION); + assert!(components[i - 1].i64(pid::LENGTH).unwrap() >= len && components[i + 1].i64(pid::LENGTH).unwrap() >= len); + let cut = component.i64(pid::CUT_POINT).unwrap(); + assert!((0..=len).contains(&cut)); + assert!(component.strong(pid::OPERATION_GROUP).is_some()); + -len + } else { + len + } + }) + .sum::(); + assert_eq!(sum, sequence.i64(pid::LENGTH).unwrap(), "sequence length = segments − transitions"); +} + +fn assert_composition(comp: &Obj) { + 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 video = slots[1].strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS); + assert_eq!(video.iter().map(|c| c.class).collect::>(), vec![cls::SOURCE_CLIP, cls::TRANSITION, cls::SOURCE_CLIP]); + assert_eq!(video[0].i64(pid::LENGTH), Some(54), "outgoing clip extends to the transition end"); + assert_eq!(video[2].i64(pid::LENGTH), Some(54), "incoming clip starts at the transition start"); + assert_eq!(video[2].i64(pid::START_TIME), Some(194)); + assert_eq!(video[1].i64(pid::CUT_POINT), Some(6)); + assert_eq!(video[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::VIDEO_DISSOLVE[..])); + assert_audio_composition_slot(&slots[2]); +} + +fn assert_audio_composition_slot(slot: &Obj) { + let audio = slot.strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS); + assert_eq!(slot.rational(pid::EDIT_RATE), Some((48_000, 1))); + assert_eq!(audio[2].class, cls::OPERATION_GROUP); + assert_eq!(audio[2].weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_GAIN[..])); + let amplitude = audio[2].objs(pid::PARAMETERS)[0].data(pid::CONSTANT_VALUE).unwrap(); + let (numerator, denominator) = store::rational_of(&litude[17..]).unwrap(); + assert!((numerator as f64 / denominator as f64 - 10f64.powf(-3.0 / 20.0)).abs() < 1e-4); + assert_eq!(audio[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_DISSOLVE[..])); +} + +#[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 [craft_cfb::Version::V3, craft_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]), craft_cfb::Version::V4).is_err()); +} + +/// The property `p` of `o` as a list of objects, to change. +fn objs_mut(o: &mut Obj, p: u16) -> &mut Vec { + match o.props.iter_mut().find(|(k, _)| *k == p).map(|(_, v)| v) { + Some(Value::StrongVec(v)) | Some(Value::StrongSet(v, _)) => v, + _ => panic!("no object list {p:#06x}"), + } +} + +fn strong_mut(o: &mut Obj, p: u16) -> &mut Obj { + match o.props.iter_mut().find(|(k, _)| *k == p).map(|(_, v)| v) { + Some(Value::Strong(v)) => v, + _ => panic!("no object {p:#06x}"), + } +} + +fn is_picture(o: &Obj) -> bool { + o.weak_key(pid::DATA_DEFINITION) == Some(&ddef::PICTURE[..]) +} + +/// How Premiere Pro 26.5.2 lays a sequence with a nested sequence out in AAF (seen in a file it +/// exported): every composition is tagged top-level, the nested one too; all video tracks of a +/// composition are in ONE slot, a nested scope with a segment per track, lowest first; a clip of +/// the nested sequence is a source clip that points at its composition. Our own file is rewritten +/// into that layout and must read back as the same edit. +#[test] +fn premiere_style_layers_and_nested_compositions_are_read() { + let r = FrameRate::FPS_24; + // "Outer": media on V1 and V3, the sequence "S" nested on V2 with its sound on A1 + let inner = document(); + let mut sources = inner.sources.clone(); + for kind in [CKind::Picture, CKind::Sound] { + let mut nest = source("seq:2", "S", kind, r, 16, None); + (nest.path, nest.nested, nest.length, nest.width, nest.height) = (None, Some(0), r.tick_of(96), 1920, 1080); + sources.push(nest); + } + let gap = |frames: i64| CItem::Filler(r.tick_of(frames)); + let outer = composition( + "Outer", + r, + vec![ + track(CKind::Picture, "V1", 1, vec![clip(r, 0, 5, 30, "a")]), + track(CKind::Picture, "V2", 2, vec![gap(10), clip(r, 2, 12, 40, "S")]), + track(CKind::Picture, "V3", 3, vec![gap(60), clip(r, 0, 100, 20, "a")]), + track(CKind::Sound, "A1", 1, vec![gap(10), clip(r, 3, 12, 40, "S")]), + ], + ); + let ours = written(&Document { compositions: vec![outer], sources, nested: inner.compositions }, false); + + let mut root = store::read(&ours).unwrap(); + let content = strong_mut(strong_mut(&mut root, pid::ROOT_HEADER), pid::CONTENT); + let mut compositions = 0; + for mob in objs_mut(content, pid::MOBS).iter_mut().filter(|m| m.class == cls::COMPOSITION_MOB) { + compositions += 1; + mob.set(pid::USAGE_CODE, Value::Data(def::USAGE_TOP_LEVEL.to_vec())); + let slots = objs_mut(mob, pid::SLOTS); + let picture: Vec = slots.iter().enumerate().filter(|(_, s)| s.strong(pid::SEGMENT).is_some_and(is_picture)).map(|(i, _)| i).collect(); + let layers: Vec = picture.iter().map(|&i| slots[i].strong(pid::SEGMENT).unwrap().clone()).collect(); + let length = layers.iter().filter_map(|l| l.i64(pid::LENGTH)).max().unwrap_or(0); + let scope = Obj::new(cls::NESTED_SCOPE) + .with(pid::DATA_DEFINITION, Value::Weak(path::DATA_DEFS.to_vec(), pid::IDENTIFICATION, ddef::PICTURE.to_vec())) + .data_prop(pid::LENGTH, length.to_le_bytes().to_vec()) + .with(pid::NESTED_SLOTS, Value::StrongVec(layers)); + // the first video slot keeps its id (clips of the nested sequence point at it) and holds + // every layer; the other video slots go + slots[picture[0]].set(pid::SEGMENT, Value::Strong(Box::new(scope))); + for &i in picture[1..].iter().rev() { + slots.remove(i); + } + } + assert_eq!(compositions, 2); + let theirs = store::write(&root, craft_cfb::Version::V4).unwrap(); + assert_ne!(theirs, ours); + + let want = read_clean(&ours); + let got = read_clean(&theirs); + // one edit; the nested composition is one it uses, not a second edit + assert_eq!(got.compositions.len(), 1); + assert_eq!(got.nested.len(), 1); + // layers of one slot share its name and are all as long as the scope: the same edit apart + // from picture track names and gaps after the last clip + let edit = |mut doc: Document| { + for t in doc.compositions.iter_mut().chain(&mut doc.nested).flat_map(|c| &mut c.tracks).filter(|t| t.kind == CKind::Picture) { + t.name.clear(); + while matches!(t.items.last(), Some(CItem::Filler(_))) { + t.items.pop(); + } + } + doc + }; + assert_eq!(edit(got.clone()), edit(want)); + // and that edit is the one that was written: three video tracks, the nest on the second + let q = &got.compositions[0]; + let picture: Vec<_> = q.tracks.iter().filter(|t| t.kind == CKind::Picture).collect(); + assert_eq!(picture.iter().map(|t| t.items.iter().filter(|i| matches!(i, CItem::Clip(_))).count()).collect::>(), [1, 1, 1]); + let nest_of = |kind: CKind| { + let t = q.tracks.iter().filter(|t| t.kind == kind).nth(if kind == CKind::Picture { 1 } else { 0 }).unwrap(); + let [CItem::Filler(gap), CItem::Clip(c), ..] = &t.items[..] else { panic!("{:?}", t.items) }; + (*gap, c.len, c.start, got.sources[c.source].nested, got.sources[c.source].name.clone()) + }; + assert_eq!(nest_of(CKind::Picture), (r.tick_of(10), r.tick_of(40), r.tick_of(12), Some(0), "S".to_string())); + assert_eq!(nest_of(CKind::Sound), nest_of(CKind::Picture)); + assert_eq!(got.nested[0].name, "S"); +} + +/// A slot's name is the track's name, whatever it starts with; a track without one reads back +/// without one. +#[test] +fn audio_tracks_keep_their_names() { + for name in ["Dialogue.Dialogue-1", "Dialogue.Dialogue-1 L", "", "é", "音声", "🎤 Dialogue", "Ä1"] { + let mut doc = document(); + doc.compositions[0].tracks[1].name = name.into(); + let back = read_clean(&written(&doc, false)); + assert_eq!(back.compositions[0].tracks[1].name, name); + } +} diff --git a/crates/aaf/src/aaf/write.rs b/crates/aaf/src/aaf/write.rs new file mode 100644 index 00000000..7ab51cea --- /dev/null +++ b/crates/aaf/src/aaf/write.rs @@ -0,0 +1,800 @@ +//! Composition model → AAF objects. + +use std::collections::HashMap; + +use craft_timeline::time::{FrameRate, Tick}; + +use super::ids::{META_DICTIONARY, ROOT, cls, ddef, def, path, pid}; +use super::store::{self, Auid, Obj, Value, rational, utf16z}; +use crate::model::{CItem, CKind, CMarker, Composition, Document, Gain, Source, ticks_to_units}; +use crate::{Error, Identity, Report, Result}; + +/// UMID-style mob id (SMPTE 330 basic UMID): label, length 0x13, instance 0, material number. +pub(crate) fn mob_id(seed: u64, n: u64) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..12].copy_from_slice(&[0x06, 0x0A, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x05, 0x01, 0x01, 0x0F, 0x20]); + id[12] = 0x13; + let a = mix(seed ^ n.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let b = mix(a ^ 0xD6E8_FEB8_6659_FD93); + id[16..24].copy_from_slice(&a.to_le_bytes()); + id[24..32].copy_from_slice(&b.to_le_bytes()); + // a "random" material number per RFC 4122 v4 layout keeps it a valid UUID + id[22] = (id[22] & 0x0F) | 0x40; + id[24] = (id[24] & 0x3F) | 0x80; + id +} + +fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +pub(crate) fn fnv(s: &[u8], h: u64) -> u64 { + s.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3)) +} + +fn timestamp() -> Vec { + // 1970-01-01 00:00:00.00: written files are reproducible + let mut v = 1970i16.to_le_bytes().to_vec(); + v.extend_from_slice(&[1, 1, 0, 0, 0, 0]); + v +} + +fn weak(path: [u16; 3], key: Auid) -> Value { + Value::Weak(path.to_vec(), pid::IDENTIFICATION, key.to_vec()) +} + +fn data_def(kind: CKind) -> Auid { + match kind { + CKind::Picture => ddef::PICTURE, + CKind::Sound => ddef::SOUND, + } +} + +/// An indirect value: byte order, type id, value. +pub(crate) fn indirect(ty: Auid, v: &[u8]) -> Vec { + let mut d = vec![0x4C]; + d.extend_from_slice(&ty); + d.extend_from_slice(v); + d +} + +fn tagged(name: &str, value: &str) -> Obj { + Obj::new(cls::TAGGED_VALUE).data_prop(pid::TAG_NAME, utf16z(name)).data_prop(pid::TAG_VALUE, indirect(def::TYPE_STRING, &utf16z(value))) +} + +fn gain_rational(g: f64) -> Vec { + rational((g.max(0.0) * 65536.0).round().min(i32::MAX as f64) as i64, 65536) +} + +fn definition(class: Auid, id: Auid, name: &str) -> Obj { + Obj::new(class).data_prop(pid::IDENTIFICATION, id.to_vec()).data_prop(pid::NAME, utf16z(name)).data_prop(pid::DESCRIPTION, utf16z("")) +} + +struct W<'a> { + report: &'a mut Report, + inexact: bool, +} + +struct IdGen { + seed: u64, + next: u64, +} + +impl IdGen { + fn for_document(doc: &Document, comp: &Composition) -> Self { + let mut seed = fnv(comp.name.as_bytes(), 0xCBF2_9CE4_8422_2325); + for source in &doc.sources { + seed = fnv(source.path.as_deref().unwrap_or("").as_bytes(), fnv(source.key.as_bytes(), seed)); + } + seed = fnv(&(comp.tracks.len() as u64).to_le_bytes(), seed); + Self { seed, next: 0 } + } + + fn next(&mut self) -> [u8; 32] { + self.next += 1; + mob_id(self.seed, self.next) + } +} + +struct MediaOutput { + mobs: Vec, + essence: Vec, + master_ref: HashMap, +} + +type FileMob = ((Option, usize, CKind), [u8; 32], Vec, usize); + +struct GroupSlots { + master_id: [u8; 32], + tape_id: [u8; 32], + timecode: Option<(i64, FrameRate)>, + master_slots: Vec, + tape_slots: Vec, + files: Vec, + master_ref: HashMap, +} + +struct GroupSlotBuilder<'a, 'b> { + doc: &'a Document, + master_id: [u8; 32], + tape_id: [u8; 32], + ids: &'b mut IdGen, + files: &'b mut Vec, + master_slots: &'b mut Vec, + tape_slots: &'b mut Vec, + master_ref: &'b mut HashMap, +} + +struct FileSlotInput<'a> { + source: &'a Source, + file_slot: u32, + length: i64, + has_timecode: bool, + tape_id: [u8; 32], + member_index: usize, + slots: &'a mut Vec, + rate: (i64, i64), +} + +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), + } +} + +fn write_media(doc: &Document, w: &mut W<'_>, ids: &mut IdGen) -> MediaOutput { + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (index, source) in doc.sources.iter().enumerate() { + if source.nested.is_some() { + continue; + } + match groups.iter_mut().find(|(key, _)| *key == source.key) { + Some((_, members)) => members.push(index), + None => groups.push((source.key.clone(), vec![index])), + } + } + + let mut output = MediaOutput { mobs: Vec::new(), essence: Vec::new(), master_ref: HashMap::new() }; + for (_, members) in groups { + write_source_group(doc, &members, w, ids, &mut output); + } + output +} + +fn write_source_group(doc: &Document, members: &[usize], w: &mut W<'_>, ids: &mut IdGen, output: &mut MediaOutput) { + let first = &doc.sources[members[0]]; + let slots = build_group_slots(doc, members, w, ids); + output.master_ref.extend(slots.master_ref); + let mut master_slots = slots.master_slots; + add_master_markers(doc, members, w, &mut master_slots); + output.mobs.push( + Obj::new(cls::MASTER_MOB) + .data_prop(pid::MOB_ID, slots.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)), + ); + let tape_id = slots.tape_id; + let timecode = slots.timecode; + let tape_slots = slots.tape_slots; + add_file_mobs(doc, slots.files, w, output); + add_tape_mob(first, tape_id, timecode, tape_slots, output); +} + +fn build_group_slots(doc: &Document, members: &[usize], w: &mut W<'_>, ids: &mut IdGen) -> GroupSlots { + let master_id = ids.next(); + let tape_id = ids.next(); + let timecode = members.iter().find_map(|&i| doc.sources[i].start_tc.map(|t| (t, doc.sources[i].tc_rate))); + let mut tape_slots = Vec::new(); + if let Some((start, rate)) = timecode { + tape_slots.push(timecode_slot(members, doc, w, start, rate)); + } + let mut files: Vec = Vec::new(); + let mut master_slots = Vec::new(); + let mut master_ref = HashMap::new(); + let mut builder = GroupSlotBuilder { + doc, + master_id, + tape_id, + ids, + files: &mut files, + master_slots: &mut master_slots, + tape_slots: &mut tape_slots, + master_ref: &mut master_ref, + }; + for (member_index, &source_index) in members.iter().enumerate() { + builder.add_source(source_index, member_index, timecode.is_some(), w); + } + GroupSlots { master_id, tape_id, timecode, master_slots, tape_slots, files, master_ref } +} + +fn timecode_slot(members: &[usize], doc: &Document, w: &mut W<'_>, start: i64, rate: FrameRate) -> Obj { + let edit_rate = (rate.num, rate.den); + let len = members.iter().map(|&i| doc.sources[i].length).max().unwrap_or(Tick::ZERO); + let units = w.u(len, edit_rate); + timeline_slot( + 1, + "TC1", + 0, + edit_rate, + Obj::new(cls::TIMECODE) + .with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::TIMECODE)) + .data_prop(pid::LENGTH, units.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]), + ) +} + +impl GroupSlotBuilder<'_, '_> { + fn add_source(&mut self, source_index: usize, member_index: usize, has_timecode: bool, w: &mut W<'_>) { + let source = &self.doc.sources[source_index]; + let rate = source_rate(source); + let embedded_key = if source.embedded.is_some() { source_index } else { usize::MAX }; + let file_key = (source.path.clone(), embedded_key, source.kind); + let file_slot = source.channel.map_or(1, |channel| channel + 1); + let len_units = w.u(source.length, rate); + let file_index = match self.files.iter().position(|file| file.0 == file_key) { + Some(index) => index, + None => { + self.files.push((file_key, self.ids.next(), Vec::new(), source_index)); + self.files.len() - 1 + } + }; + let file_id = self.files[file_index].1; + add_file_slot(FileSlotInput { + source, + file_slot, + length: len_units, + has_timecode, + tape_id: self.tape_id, + member_index, + slots: &mut self.files[file_index].2, + rate, + }); + if has_timecode { + let tape_slot_id = 2 + member_index as u32; + self.tape_slots.push(timeline_slot( + tape_slot_id, + &slot_name(source.kind, member_index as u32 + 1), + member_index as u32 + 1, + rate, + source_clip(source.kind, len_units, [0; 32], 0, 0), + )); + } + let master_slot = member_index as u32 + 1; + self.master_slots.push(timeline_slot( + master_slot, + &slot_name(source.kind, master_slot), + master_slot, + rate, + source_clip(source.kind, len_units, file_id, file_slot, 0), + )); + self.master_ref.insert(source_index, (self.master_id, master_slot)); + } +} + +fn add_file_slot(input: FileSlotInput<'_>) { + let FileSlotInput { source, file_slot, length, has_timecode, tape_id, member_index, slots, rate } = input; + if slots.iter().any(|slot| slot.u32(pid::SLOT_ID) == Some(file_slot)) { + return; + } + let tape_slot_id = if has_timecode { 2 + member_index as u32 } else { 0 }; + let mob_id = if has_timecode { tape_id } else { [0; 32] }; + let clip = source_clip(source.kind, length, mob_id, tape_slot_id, 0); + slots.push(timeline_slot(file_slot, &slot_name(source.kind, file_slot), file_slot, rate, clip)); +} + +fn add_master_markers(doc: &Document, members: &[usize], w: &mut W<'_>, master_slots: &mut Vec) { + let first = &doc.sources[members[0]]; + let marker_rate = + members.iter().map(|&i| &doc.sources[i]).find(|source| source.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() { + return; + } + let mut unique: Vec = markers.into_iter().cloned().collect(); + unique.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name))); + unique.dedup(); + master_slots.push(event_slot(w, members.len() as u32 + 1, marker_rate, &unique)); +} + +fn add_file_mobs(doc: &Document, files: Vec, w: &mut W<'_>, output: &mut MediaOutput) { + for (_, file_id, slots, source_index) in files { + let source = &doc.sources[source_index]; + let desc = descriptor(w, source); + output.mobs.push( + Obj::new(cls::SOURCE_MOB) + .data_prop(pid::MOB_ID, file_id.to_vec()) + .data_prop(pid::MOB_NAME, utf16z(&source.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) = &source.embedded { + output.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())), + ); + } + } +} + +fn add_tape_mob(first: &Source, tape_id: [u8; 32], timecode: Option<(i64, FrameRate)>, tape_slots: Vec, output: &mut MediaOutput) { + let Some(_) = timecode else { return }; + let reel = first.path.as_deref().map(craft_timeline::path::file_stem).unwrap_or(&first.name).to_string(); + output.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)))), + ); +} + +/// Serialise a composition document. +pub(crate) fn write(doc: &Document, version: craft_cfb::Version, identity: &Identity<'_>, report: &mut Report) -> Result> { + let comp = doc.compositions.first().ok_or(Error::Empty)?; + let mut w = W { report, inexact: false }; + let mut ids = IdGen::for_document(doc, comp); + let MediaOutput { mut mobs, essence, master_ref } = write_media(doc, &mut w, &mut ids); + let mut used_ops: Vec = Vec::new(); + let mut uses_varying = false; + + // ---- nested compositions: a clip refers to one as it does to a master mob, through the + // composition's first track of the clip's kind (a broken-out channel: that channel's track) + let mut refs = Refs { mobs: master_ref, rates: HashMap::new() }; + let nested_ids: Vec<[u8; 32]> = doc.nested.iter().map(|_| ids.next()).collect(); + for (si, s) in doc.sources.iter().enumerate() { + let Some((comp, id)) = s.nested.and_then(|n| Some((doc.nested.get(n)?, nested_ids.get(n)?))) else { continue }; + let of_kind: Vec = comp.tracks.iter().enumerate().filter(|(_, t)| t.kind == s.kind).map(|(i, _)| i).collect(); + let Some(&track) = s.channel.and_then(|c| of_kind.get(c as usize)).or(of_kind.first()) else { continue }; + let rate = match s.kind { + CKind::Picture => (comp.rate.num, comp.rate.den), + CKind::Sound => (comp.sample_rate.max(1) as i64, 1), + }; + refs.mobs.insert(si, (*id, track as u32 + 2)); + refs.rates.insert(si, rate); + } + + // ---- the compositions: the exported sequence first + let comp_id = ids.next(); + let composition_mobs = { + let mut composition_writer = CompositionWriter { w: &mut w, doc, refs: &refs, used_ops: &mut used_ops, uses_varying: &mut uses_varying }; + let mut output = vec![composition_writer.write(comp, comp_id, true)]; + output.extend(doc.nested.iter().zip(&nested_ids).map(|(nested, id)| composition_writer.write(nested, *id, false))); + output + }; + mobs.splice(0..0, composition_mobs); + if w.inexact { + w.report.info("some times are not on whole edit units and were rounded"); + } + + // ---- dictionary + let mut ddefs = Vec::new(); + for (id, name) in + [(ddef::PICTURE, "Picture"), (ddef::SOUND, "Sound"), (ddef::TIMECODE, "Timecode"), (ddef::DESCRIPTIVE_METADATA, "DescriptiveMetadata")] + { + ddefs.push(definition(cls::DATA_DEFINITION, id, name)); + } + let mut opdefs = Vec::new(); + for op in &used_ops { + let (name, dd, inputs) = match *op { + x if x == def::VIDEO_DISSOLVE => ("Video Dissolve", ddef::PICTURE, 2), + x if x == def::VIDEO_FADE_TO_BLACK => ("Video Fade To Black", ddef::PICTURE, 1), + x if x == def::SMPTE_VIDEO_WIPE => ("SMPTE Video Wipe", ddef::PICTURE, 2), + x if x == def::MONO_AUDIO_DISSOLVE => ("Mono Audio Dissolve", ddef::SOUND, 2), + _ => ("Mono Audio Gain", ddef::SOUND, 1i32), + }; + let params: Vec> = if *op == def::MONO_AUDIO_GAIN { + vec![def::PARAM_AMPLITUDE.to_vec()] + } else if *op == def::VIDEO_DISSOLVE { + vec![def::PARAM_LEVEL.to_vec()] + } else { + Vec::new() + }; + let mut o = definition(cls::OPERATION_DEFINITION, *op, name) + .with(pid::OPDEF_DATA_DEFINITION, weak(path::DATA_DEFS, dd)) + .data_prop(pid::OPDEF_IS_TIME_WARP, vec![0]) + .data_prop(pid::OPDEF_NUMBER_INPUTS, inputs.to_le_bytes().to_vec()); + if !params.is_empty() { + o.set(pid::OPDEF_PARAMETERS_DEFINED, Value::WeakVec(path::PARAMETER_DEFS.to_vec(), pid::IDENTIFICATION, params)); + } + opdefs.push(o); + } + let pdefs = vec![ + definition(cls::PARAMETER_DEFINITION, def::PARAM_LEVEL, "Level").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")), + definition(cls::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE, "Amplitude").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")), + ]; + let idefs = if uses_varying || !used_ops.is_empty() { + vec![ + definition(cls::INTERPOLATION_DEFINITION, def::INTERP_LINEAR, "Linear"), + definition(cls::INTERPOLATION_DEFINITION, def::INTERP_CONSTANT, "Constant"), + ] + } else { + Vec::new() + }; + let cdefs = vec![ + definition(cls::CONTAINER_DEFINITION, def::CONTAINER_EXTERNAL, "External Container"), + definition(cls::CONTAINER_DEFINITION, def::CONTAINER_AAF, "AAF Container"), + ]; + let mut dict = Obj::new(cls::DICTIONARY) + .with(pid::DATA_DEFINITIONS, Value::StrongSet(ddefs, pid::IDENTIFICATION)) + .with(pid::PARAMETER_DEFINITIONS, Value::StrongSet(pdefs, pid::IDENTIFICATION)) + .with(pid::CONTAINER_DEFINITIONS, Value::StrongSet(cdefs, pid::IDENTIFICATION)); + if !opdefs.is_empty() { + dict.set(pid::OPERATION_DEFINITIONS, Value::StrongSet(opdefs, pid::IDENTIFICATION)); + } + if !idefs.is_empty() { + dict.set(pid::INTERPOLATION_DEFINITIONS, Value::StrongSet(idefs, pid::IDENTIFICATION)); + } + + let mut content = Obj::new(cls::CONTENT_STORAGE).with(pid::MOBS, Value::StrongSet(mobs, pid::MOB_ID)); + if !essence.is_empty() { + content.set(pid::ESSENCE_DATA, Value::StrongSet(essence, pid::ESSENCE_MOB_ID)); + } + let ident = Obj::new(cls::IDENTIFICATION) + .data_prop(pid::COMPANY_NAME, utf16z(identity.company)) + .data_prop(pid::PRODUCT_NAME, utf16z(identity.product)) + .data_prop(pid::PRODUCT_VERSION_STRING, utf16z(identity.version)) + .data_prop(pid::PRODUCT_ID, identity.product_id.to_vec()) + .data_prop(pid::DATE, timestamp()) + .data_prop(pid::PLATFORM, utf16z(identity.platform)) + .data_prop(pid::GENERATION_AUID, mob_id(ids.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])); + // The meta-dictionary holds only extensions to the baseline object model, and everything written + // here is baseline, so its class and type definition sets are empty; readers that load them + // (pyaaf2) still need the sets to be there (filmcraft#323). + let meta = Obj::new(META_DICTIONARY) + .with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)) + .with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)); + let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header))); + store::write(&root, version).map_err(Error::Other) +} + +/// Where clips find their sources: source index → (mob id, slot id), and for nested compositions +/// the edit rate of that slot. +#[derive(Default)] +struct Refs { + mobs: HashMap, + rates: HashMap, +} + +/// The composition mob of `comp`. Slot 1 is its timecode; its tracks follow from slot 2 in order. +struct CompositionWriter<'a, 'b, 'c> { + w: &'a mut W<'c>, + doc: &'b Document, + refs: &'b Refs, + used_ops: &'a mut Vec, + uses_varying: &'a mut bool, +} + +impl CompositionWriter<'_, '_, '_> { + fn write(&mut self, comp: &Composition, id: [u8; 32], top: bool) -> Obj { + let w = &mut *self.w; + let doc = self.doc; + let refs = self.refs; + let used_ops = &mut *self.used_ops; + let uses_varying = &mut *self.uses_varying; + 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) = refs.mobs.get(&c.source).copied().unwrap_or(([0; 32], 0)); + // (a nested composition counts the start in its own slot's edit units) + let start = w.u(c.start, refs.rates.get(&c.source).copied().unwrap_or(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(w, slot_id, crate_rate, &comp.markers)); + } + let mut mob = Obj::new(cls::COMPOSITION_MOB) + .data_prop(pid::MOB_ID, 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()); + // only the exported sequence is a top-level composition; a nested sequence is found through + // the clips that use it + if top { + mob.set(pid::USAGE_CODE, Value::Data(def::USAGE_TOP_LEVEL.to_vec())); + } + mob.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()), + ]), + ) + } +} + +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 => { + // Channel counts and rates come from the project: saturate, as these fields can't + // hold more anyway. + let block = s.file_channels.max(1).saturating_mul((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, u16::try_from(block).unwrap_or(u16::MAX).to_le_bytes().to_vec()) + .data_prop(pid::AVERAGE_BPS, block.saturating_mul(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 craft_timeline::path::is_absolute(p) { craft_timeline::path::path_to_file_url(p, false) } else { p.clone() }; + d.set(pid::LOCATOR, Value::StrongVec(vec![Obj::new(cls::NETWORK_LOCATOR).data_prop(pid::URL_STRING, utf16z(&url))])); + } + d +} diff --git a/crates/aaf/src/lib.rs b/crates/aaf/src/lib.rs new file mode 100644 index 00000000..ee7448bc --- /dev/null +++ b/crates/aaf/src/lib.rs @@ -0,0 +1,60 @@ +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +//! AAF and OMF: the two mob-based editorial interchange formats, read and written through one +//! composition model. +//! +//! - [`model`]: a [`model::Document`] of compositions (tracks of fillers, source clips and +//! overlapping transitions, with clip gain and markers) and the media sources they reference, +//! with exact times ([`craft_timeline::time::Tick`]). +//! - [`aaf`]: Advanced Authoring Format, Edit Protocol, in structured storage (`craft-cfb`). +//! - [`omf`]: OMF Interchange 2.0 in a Bento container; sound only. +//! +//! Both read into and write from the same model, so an application maps its timeline onto the +//! model once. Audio essence can be embedded: the model carries it as PCM, and the WAVE / AIFF +//! wrappers the formats use are craft-audio's. +//! +//! Clean-room: written from the public specifications only (AMWA *AAF Object Specification* v1.1, +//! *AAF Low-Level Container Specification* v1.0.1, *AAF Edit Protocol* v1.0, SMPTE RP 224 / RP 210 +//! labels; Avid *OMF Interchange Specification* 2.0, Apple *Bento Specification* 1.0d5). No file +//! I/O: documents are bytes and media references are strings. +//! +//! Input is untrusted: a damaged or crafted file gives an [`Error`] or a [`Report`] entry, never +//! a panic, a hang or an allocation sized by a count in the file. + +pub mod aaf; +pub mod model; +pub mod omf; +mod pcm; +#[cfg(test)] +mod roundtrip_tests; +#[cfg(test)] +mod testdoc; + +pub use craft_timeline::report::Report; + +/// Error from a reader or writer. Unsupported *features* never error; they go in the [`Report`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a readable document of the format. + #[error("{format} parse error: {message}")] + Parse { format: &'static str, message: String }, + /// The document has no composition. + #[error("the document contains no timeline")] + Empty, + /// The document could not be written. + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Who a written file says made it (the AAF `Identification` object, the OMF `IDNT` object). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Identity<'a> { + pub company: &'a str, + pub product: &'a str, + /// The product's version, as text. + pub version: &'a str, + pub platform: &'a str, + /// AAF `ProductID`: an AUID naming the product. + pub product_id: [u8; 16], +} diff --git a/crates/aaf/src/model.rs b/crates/aaf/src/model.rs new file mode 100644 index 00000000..3dff507b --- /dev/null +++ b/crates/aaf/src/model.rs @@ -0,0 +1,185 @@ +//! 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: here, a [`Source`] per essence, grouped into one media item by its +//! key. All times are ticks; the format serialisers convert to and from edit units. + +use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick}; + +/// Picture or sound. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CKind { + Picture, + Sound, +} + +/// A whole document: compositions and the media they reference. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Document { + /// The top-level compositions. + pub compositions: Vec, + pub sources: Vec, + /// Compositions that are only used inside others (nested sequences); a [`Source`] refers to + /// one by its index here. They may nest further. + pub nested: Vec, +} + +/// How a nested sequence is written. Premiere Pro writes one as a composition of its own in AAF +/// (clips refer to it the way they refer to media) and renders its sound in OMF, which has no +#[derive(Clone, Debug, PartialEq)] +pub struct Composition { + pub name: String, + pub rate: FrameRate, + pub sample_rate: u32, + pub width: u32, + pub height: u32, + pub start_tc: i64, + pub drop: bool, + pub tracks: Vec, + pub markers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CTrack { + pub kind: CKind, + pub name: String, + /// Physical track number (1-based, per kind). + pub number: u32, + /// Sound: channels of the track (1 mono, 2 stereo, 6 5.1). + pub channels: u32, + pub items: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CItem { + Filler(Tick), + Clip(CClip), + Transition(CTransition), +} + +impl CItem { + /// The item's length on the track. + 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 struct CClip { + pub len: Tick, + /// Index into [`Document::sources`]. + pub source: usize, + /// Source (file) time of the segment's first frame. + pub start: Tick, + pub gain: Option, + pub name: String, +} + +/// Clip gain (linear amplitude). +#[derive(Clone, Debug, PartialEq)] +pub 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 struct CTransition { + pub len: Tick, + /// Where the cut lies, from the transition start. + pub cut: Tick, + /// The transition, by a name of the writing application's choosing; it is kept in a private tag + /// and comes back unchanged. Names the formats themselves can say: `cross_dissolve` and + /// `dip_to_black` (picture), any name containing `wipe` (a SMPTE wipe), `constant_power` (sound). + /// A file without the tag reads as one of these. + pub effect: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CMarker { + pub start: Tick, + pub duration: Tick, + pub name: String, + pub comment: String, + pub color: Option, +} + +/// One essence: a file (or embedded data) carrying the picture, or some channels of sound, of a +/// media item. +#[derive(Clone, Debug, PartialEq)] +pub struct Source { + /// Groups the sources of one media item (one master mob). + pub key: String, + pub name: String, + pub kind: CKind, + pub path: Option, + /// Sound: the (0-based) channel of the file this source exposes (broken out to mono), or + /// `None` for all of them. + pub channel: Option, + /// Sound: channels this source exposes (1 when `channel` is set, else `file_channels`). + pub channels: u32, + /// Sound: channels of the file / embedded data. + pub file_channels: u32, + pub width: u32, + pub height: u32, + /// Picture: the frame rate of the media. + pub frame_rate: FrameRate, + pub sample_rate: u32, + pub bits: u16, + pub length: Tick, + /// Media time of the essence's time 0 (trimmed / consolidated essence). + pub offset: Tick, + /// Start timecode in frames at `tc_rate`. + pub start_tc: Option, + pub tc_rate: FrameRate, + pub embedded: Option>, + pub markers: Vec, + /// Not media but a nested composition: its index in [`Document::nested`]. `start` of a clip + /// that uses it is a time in that composition. + pub nested: Option, +} + +impl Source { + /// A picture source with no length: a still image. + pub fn is_still(&self) -> bool { + self.kind == CKind::Picture && self.length == Tick::ZERO + } +} + +/// The largest time read from a document (about 3.3 days). +pub const MAX_TIME: Tick = Tick(1 << 56); + +/// Ticks of `n` edit units at rate `num/den` (rounded to the nearest tick). +pub 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 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) +} diff --git a/crates/aaf/src/omf.rs b/crates/aaf/src/omf.rs new file mode 100644 index 00000000..57e0f756 --- /dev/null +++ b/crates/aaf/src/omf.rs @@ -0,0 +1,47 @@ +//! OMF Interchange 2.0 for audio post: written, and the same structures read. +//! +//! 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. +//! +//! Only sound is written: picture tracks and markers of the model are left out, and a nested +//! composition is not written as one (readers of OMF do not follow compositions). A caller with +//! nested sequences mixes their sound and passes it as media like any other; Premiere Pro's OMF +//! export does the same. +//! +//! Private properties. `FilmCraft:SCLP:ClipName` (a clip's name) and `FilmCraft:EFFE:EffectID` (a +//! transition's name) are not in the specification; they were first written by FilmCraft and keep +//! their names so that existing files read back. + +pub(crate) mod bento; +mod read; +mod write; + +use crate::model::Document; +use crate::{Identity, Report, Result}; + +/// Write the sound of `doc`'s first composition as an OMF 2.0 file. +pub fn write(doc: &Document, identity: &Identity<'_>, report: &mut Report) -> Result> { + write::write(doc, identity, report) +} + +/// Read an OMF 2.0 file (as written by [`write`] and by applications using the same structures). +pub fn read(bytes: &[u8], report: &mut Report) -> Result { + read::read(bytes, 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; diff --git a/crates/aaf/src/omf/bento.rs b/crates/aaf/src/omf/bento.rs new file mode 100644 index 00000000..00a7e2a2 --- /dev/null +++ b/crates/aaf/src/omf/bento.rs @@ -0,0 +1,407 @@ +//! 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), + At(u64, u32), +} + +/// Bento values are metadata, not essence payloads. Limit reassembled segments so repeated +/// references cannot amplify a small OMF into an enormous allocation. +const MAX_VALUE_BYTES: usize = 16 << 20; + +/// Most bytes of global names (property and type names) kept for one container. Real files name a +/// few hundred short strings; without a cap, many objects naming one large shared range would each +/// retain their own copy. +const MAX_NAME_BYTES: usize = 4 << 20; + +/// Builds a Bento container. +pub(crate) struct Writer { + data: Vec, + names: HashMap<(u32, String), u32>, + next: u32, + /// (object, property, type, segments) + entries: Vec<(u32, u32, u32, Vec)>, +} + +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 { + 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, +} + +/// A parsed Bento container. +pub(crate) struct Container<'a> { + data: &'a [u8], + pub objects: BTreeMap>, + names: HashMap, + by_name: HashMap, +} + +fn be32(b: &[u8], at: usize) -> Option { + 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, 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 = c.objects.keys().copied().collect(); + let mut name_bytes = 0usize; + 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 { + name_bytes = name_bytes.saturating_add(n.len()); + if name_bytes > MAX_NAME_BYTES { + return Err("Bento global names exceed the name budget".into()); + } + 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, String> { + let total = p.segs.iter().try_fold(0usize, |total, s| { + let len = match s { + Seg::Imm(b) => b.len(), + Seg::At(_, len) => *len as usize, + }; + total.checked_add(len).ok_or_else(|| "Bento value length overflows".to_string()) + })?; + if total > MAX_VALUE_BYTES { + return Err("Bento value exceeds the decoded-value budget".into()); + } + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| "cannot allocate Bento value")?; + 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 { + 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> { + 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.as_chunks::<8>().0 { + if be32(pair, 0) == Some(key) { + return be32(pair, 4).unwrap_or(key); + } + } + } + key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn many_objects_cannot_name_themselves_with_one_shared_range() { + let mut w = Writer::new(); + w.data = vec![b'a'; 1 << 20]; + let ids: Vec = (0..8).map(|_| w.new_object()).collect(); + for id in ids { + w.entries.push((id, GLOBAL_PROP_NAME, TYPE_7BIT_ASCII, vec![Seg::At(0, 1 << 20)])); + } + let file = w.finish(); + assert!(Container::open(&file).err().is_some_and(|e| e.contains("name budget"))); + } + + #[test] + fn repeated_segments_cannot_expand_a_value_past_its_budget() { + let p = Prop { prop: 1, ty: 1, segs: vec![Seg::At(0, MAX_VALUE_BYTES as u32), Seg::Imm(vec![0])] }; + let c = Container { data: &[], objects: BTreeMap::new(), names: HashMap::new(), by_name: HashMap::new() }; + assert!(c.value(&p).unwrap_err().contains("budget")); + } +} diff --git a/crates/aaf/src/omf/read.rs b/crates/aaf/src/omf/read.rs new file mode 100644 index 00000000..50e4b270 --- /dev/null +++ b/crates/aaf/src/omf/read.rs @@ -0,0 +1,438 @@ +//! OMF 2.0 objects (Bento) → composition model. + +use std::collections::{HashMap, HashSet}; + +use craft_timeline::time::{FrameRate, Tick}; + +use super::bento::Container; +use crate::model::{CClip, CItem, CKind, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks}; +use crate::{Error, Report, Result}; + +type Uid = [u8; 12]; + +const MAX_REFERENCE_VISITS: usize = 65_536; + +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, + media: HashMap>, + doc: Document, + sources: HashMap<(Uid, u32, String), usize>, +} + +pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result { + 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 = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect(); + let mut referenced = HashSet::new(); + let mut visited = HashSet::new(); + let mut visits = 0; + for &m in &cmobs { + r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0); + } + let primary: Vec = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect(); + let top: Vec = 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 { + 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 { + 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 { + self.c.get(o, p).and_then(|v| self.u32_of(&v)) + } + fn u16(&self, o: u32, p: &str) -> Option { + 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 { + 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 { + 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 { + 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 { + 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..].as_chunks::<4>().0.iter().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, visited: &mut HashSet, visits: &mut usize, depth: usize) { + if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS { + return; + } + *visits += 1; + 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, visited, visits, 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, visited, visits, 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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::pcm::parse_wav(&summary).or_else(|| crate::pcm::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| craft_timeline::path::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(), + nested: None, + }; + if let Some(d) = self.media.get(&id) { + if let Some((pcm, ch, sr, b)) = crate::pcm::parse_wav(d).or_else(|| crate::pcm::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 + } + } + } +} diff --git a/crates/aaf/src/omf/tests.rs b/crates/aaf/src/omf/tests.rs new file mode 100644 index 00000000..c6f4ef9f --- /dev/null +++ b/crates/aaf/src/omf/tests.rs @@ -0,0 +1,135 @@ +//! Structural checks of written OMF files: the Bento label and TOC, and the required properties of +//! the OMF Interchange 2.0 classes written. + +use std::collections::HashMap; + +use super::bento::{self, Container}; +use super::*; +use crate::testdoc::{IDENTITY, sound_only}; + +/// Every object of a class written has the properties the specification requires of it. +fn assert_required_properties(c: &Container<'_>, by_class: &HashMap>) { + 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}"); + } + } + } +} + +#[test] +fn written_files_have_the_required_structure() { + let mut report = Report::default(); + let bytes = write(&sound_only(Some(vec![7; 480 * 4])), &IDENTITY, &mut report).unwrap(); + assert!(!report.has_warnings(), "{report}"); + // 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> = HashMap::new(); + for &o in c.objects.keys() { + if let Some(k) = class(o) { + by_class.entry(k).or_default().push(o); + } + } + assert_required_properties(&c, &by_class); + 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 { + let v = c.get(o, p).unwrap(); + let n = u16::from_be_bytes([v[0], v[1]]) as usize; + v[2..].as_chunks::<4>().0.iter().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::pcm::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 = 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()); +} diff --git a/crates/aaf/src/omf/write.rs b/crates/aaf/src/omf/write.rs new file mode 100644 index 00000000..75599480 --- /dev/null +++ b/crates/aaf/src/omf/write.rs @@ -0,0 +1,419 @@ +//! Composition model → OMF 2.0 objects in a Bento container. + +use std::collections::HashMap; + +use craft_timeline::time::Tick; + +use super::bento; +use crate::model::{CItem, CKind, Document, Gain, Source, ticks_to_units}; +use crate::{Error, Identity, 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>, + ddefs: HashMap<&'static str, u32>, + edefs: HashMap<&'static str, u32>, + defs: Vec, + report: &'a mut Report, + inexact: bool, +} + +fn rational(n: i64, d: i64) -> Vec { + 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 { + 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", &(*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", &(*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 { + 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, identity: &Identity<'_>, report: &mut Report) -> Result> { + 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 = HashMap::new(); + // media + let mut groups: Vec<(String, Vec)> = 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", + &craft_audio::pcm_wav_file(pcm, u16::try_from(s.file_channels.max(1)).unwrap_or(u16::MAX), 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(craft_timeline::path::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", identity.company); + w.str(ident, "OMFI:IDNT:ProductName", identity.product); + w.str(ident, "OMFI:IDNT:ProductVersionString", identity.version); + w.str(ident, "OMFI:IDNT:Platform", identity.platform); + 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!(craft_timeline::path::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 = u32::try_from(len.max(0)).unwrap_or(u32::MAX); + // Channel counts come from the project: saturate, as the headers can't hold more anyway. + let bytes_per_frame = s.file_channels.max(1).saturating_mul((s.bits as u32).div_ceil(8)); + let channels = u16::try_from(s.file_channels.max(1)).unwrap_or(u16::MAX); + if aiff { + w.b.set(d, "OMFI:AIFD:Summary", "omfi:DataValue", &craft_audio::pcm_aiff_header(frames, channels, s.sample_rate, s.bits)); + } else { + w.b.set( + d, + "OMFI:WAVD:Summary", + "omfi:DataValue", + &craft_audio::pcm_wav_header(frames.saturating_mul(bytes_per_frame), channels, 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 craft_timeline::path::is_absolute(p) { + let n = w.obj(b"NETL"); + w.str(n, "OMFI:NETL:URLString", &craft_timeline::path::path_to_file_url(p, false)); + locs.push(n); + } + w.refs(d, "OMFI:MDES:Locator", &locs); + } + d +} diff --git a/crates/aaf/src/pcm.rs b/crates/aaf/src/pcm.rs new file mode 100644 index 00000000..789c976a --- /dev/null +++ b/crates/aaf/src/pcm.rs @@ -0,0 +1,11 @@ +//! Embedded PCM essence in and out of WAVE and AIFF wrappers (craft-audio does the containers). + +/// PCM (little-endian, 8-bit unsigned), channels, sample rate and bits of a WAVE file. +pub(crate) fn parse_wav(b: &[u8]) -> Option<(Vec, u16, u32, u16)> { + craft_audio::wav_pcm(b).map(|p| (p.data, p.channels, p.sample_rate, p.bits)) +} + +/// The same for an AIFF / AIFF-C (`NONE`, `twos`, `sowt`) file. +pub(crate) fn parse_aiff(b: &[u8]) -> Option<(Vec, u16, u32, u16)> { + craft_audio::aiff_pcm(b).map(|p| (p.data, p.channels, p.sample_rate, p.bits)) +} diff --git a/crates/aaf/src/roundtrip_tests.rs b/crates/aaf/src/roundtrip_tests.rs new file mode 100644 index 00000000..5ae2a6d8 --- /dev/null +++ b/crates/aaf/src/roundtrip_tests.rs @@ -0,0 +1,217 @@ +//! Documents written and read back, in both formats; damaged files. + +use craft_timeline::time::{FrameRate, Tick}; + +use crate::model::{CItem, CKind, CMarker, Document, Gain}; +use crate::testdoc::{IDENTITY, pcm_ramp, picture_and_sound, sound_only}; +use crate::{Error, Report, aaf, omf}; + +fn aaf_bytes(doc: &Document, small_sectors: bool) -> Vec { + let mut report = Report::default(); + let bytes = aaf::write(doc, &aaf::WriteOptions { identity: IDENTITY, small_sectors }, &mut report).unwrap(); + assert!(report.is_empty(), "{report}"); + bytes +} + +fn omf_bytes(doc: &Document) -> Vec { + let mut report = Report::default(); + let bytes = omf::write(doc, &IDENTITY, &mut report).unwrap(); + assert!(!report.has_warnings(), "{report}"); + bytes +} + +fn read_clean(bytes: &[u8], is_aaf: bool) -> Document { + let mut report = Report::default(); + let doc = if is_aaf { aaf::read(bytes, &mut report) } else { omf::read(bytes, &mut report) }.unwrap(); + assert!(!report.has_warnings(), "{report}"); + doc +} + +/// What a track holds, with each clip's source by what it is rather than by index. Gain is stored +/// as a rational: four decimals survive. +fn shape(doc: &Document) -> Vec<(CKind, String, u32, Vec)> { + let comp = &doc.compositions[0]; + comp.tracks + .iter() + .map(|t| { + let items = t + .items + .iter() + .map(|i| match i { + CItem::Filler(l) => format!("gap {}", l.0), + CItem::Transition(x) => format!("{} {} cut {}", x.effect, x.len.0, x.cut.0), + CItem::Clip(c) => { + let s = &doc.sources[c.source]; + let gain = match &c.gain { + None => "unity".to_string(), + Some(Gain::Constant(g)) => format!("{g:.4}"), + Some(Gain::Varying { linear, points }) => { + format!("{linear} {:?}", points.iter().map(|(t, g)| (t.0, format!("{g:.4}"))).collect::>()) + } + }; + format!("clip {} from {} of {:?} {:?} gain {gain}", c.len.0, c.start.0, s.path, s.kind) + } + }) + .collect(); + (t.kind, t.name.clone(), t.number, items) + }) + .collect() +} + +#[test] +fn aaf_round_trips_the_edit_at_every_rate() { + for (rate, drop) in [(FrameRate::FPS_25, false), (FrameRate::FPS_29_97, true), (FrameRate::FPS_23_976, false), (FrameRate::FPS_59_94, true)] { + let mut doc = picture_and_sound(rate); + let comp = &mut doc.compositions[0]; + (comp.start_tc, comp.drop) = (rate.timecode_base() * 3600, drop); + comp.markers = vec![ + CMarker { start: rate.tick_of(10), duration: Tick::ZERO, name: "Slate".into(), comment: "take 2".into(), color: Some("Green".into()) }, + CMarker { start: rate.tick_of(40), duration: rate.tick_of(5), name: "Range".into(), comment: String::new(), color: None }, + ]; + // keyframed gain on the first sound clip, held gain steps on nothing else + if let Some(CItem::Clip(c)) = comp.tracks[1].items.first_mut() { + c.gain = Some(Gain::Varying { linear: true, points: vec![(Tick::ZERO, 1.0), (rate.tick_of(25), 0.5)] }); + } + doc.sources[0].markers = + vec![CMarker { start: rate.tick_of(30), duration: Tick::ZERO, name: "In media".into(), comment: String::new(), color: None }]; + for small in [false, true] { + let back = read_clean(&aaf_bytes(&doc, small), true); + let (want, got) = (&doc.compositions[0], &back.compositions[0]); + assert_eq!(shape(&back), shape(&doc), "{rate} small {small}"); + assert_eq!((&got.name, got.rate, got.start_tc, got.drop), (&want.name, want.rate, want.start_tc, want.drop), "{rate}"); + assert_eq!((got.width, got.height, got.sample_rate), (1920, 1080, 48_000), "{rate}"); + let markers = |m: &[CMarker]| m.iter().map(|m| (m.start, m.duration, m.name.clone(), m.comment.clone())).collect::>(); + assert_eq!(markers(&got.markers), markers(&want.markers), "{rate}"); + assert_eq!(back.nested, []); + let picture = back.sources.iter().find(|s| s.kind == CKind::Picture).unwrap(); + assert_eq!((picture.width, picture.height, picture.frame_rate, picture.length), (1280, 720, rate, doc.sources[0].length), "{rate}"); + assert_eq!((picture.start_tc, picture.tc_rate), (Some(86_400), rate), "{rate}"); + assert_eq!(markers(&picture.markers), markers(&doc.sources[0].markers), "{rate}"); + let sound = back.sources.iter().find(|s| s.kind == CKind::Sound).unwrap(); + assert_eq!((sound.channels, sound.file_channels, sound.sample_rate, sound.bits), (2, 2, 48_000, 24), "{rate}"); + assert_eq!(sound.key, picture.key, "one media item"); + // what was read, written again, reads as the same edit (mob ids, and so source keys, are new) + let again = read_clean(&aaf_bytes(&back, small), true); + assert_eq!((shape(&again), &again.compositions), (shape(&back), &back.compositions), "{rate}"); + } + } +} + +#[test] +fn embedded_audio_comes_back_bit_for_bit() { + let pcm = pcm_ramp(480, 2, 3); + let mut doc = sound_only(Some(pcm.clone())); + // trimmed essence: its first sample is media time 0.2 s + doc.sources[0].offset = Tick::from_units(9_600, 48_000); + for is_aaf in [true, false] { + let bytes = if is_aaf { aaf_bytes(&doc, false) } else { omf_bytes(&doc) }; + assert_eq!((aaf::sniff(&bytes), omf::sniff(&bytes)), (is_aaf, !is_aaf)); + let back = read_clean(&bytes, is_aaf); + let s = &back.sources[0]; + assert_eq!(s.embedded.as_deref(), Some(&pcm[..]), "aaf {is_aaf}"); + assert_eq!((s.path.as_deref(), s.file_channels, s.sample_rate, s.bits), (None, 2, 48_000, 16), "aaf {is_aaf}"); + assert_eq!(s.length, Tick::from_units(480, 48_000), "aaf {is_aaf}"); + assert_eq!(shape(&back), shape(&doc), "aaf {is_aaf}"); + } +} + +#[test] +fn omf_round_trips_sound_linked_to_wave_and_aiff_files() { + for path in ["/m/a.wav", "/m/a.aif"] { + let mut doc = sound_only(None); + doc.sources[0].path = Some(path.into()); + let back = read_clean(&omf_bytes(&doc), false); + assert_eq!(shape(&back), shape(&doc), "{path}"); + let s = &back.sources[0]; + assert_eq!((s.file_channels, s.sample_rate, s.bits, s.embedded.as_ref()), (2, 48_000, 16, None), "{path}"); + assert_eq!(back.compositions[0].rate, FrameRate::FPS_25); + } +} + +#[test] +fn omf_leaves_picture_out() { + let doc = picture_and_sound(FrameRate::FPS_25); + let back = read_clean(&omf_bytes(&doc), false); + assert!(back.compositions[0].tracks.iter().all(|t| t.kind == CKind::Sound)); + assert_eq!(shape(&back).len(), 1); +} + +#[test] +fn empty_and_foreign_documents() { + let none = Document::default(); + assert_eq!(aaf::write(&none, &aaf::WriteOptions { identity: IDENTITY, small_sectors: false }, &mut Report::default()), Err(Error::Empty)); + assert_eq!(omf::write(&none, &IDENTITY, &mut Report::default()), Err(Error::Empty)); + let mut empty = sound_only(None); + empty.compositions[0].tracks.clear(); + empty.sources.clear(); + for small in [false, true] { + assert_eq!(read_clean(&aaf_bytes(&empty, small), true).compositions.len(), 1); + } + assert_eq!(read_clean(&omf_bytes(&empty), false).compositions.len(), 1); + assert!(matches!(aaf::read(b"not an aaf", &mut Report::default()), Err(Error::Parse { format: "AAF", .. }))); + assert!(matches!(omf::read(b"not an omf", &mut Report::default()), Err(Error::Parse { format: "OMF", .. }))); + assert!(!aaf::sniff(b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1 but short")); + assert!(!omf::sniff(b"")); +} + +/// Truncation and random corruption: an error or a document, never a panic. +#[test] +fn damaged_documents_never_panic() { + let aaf_file = aaf_bytes(&picture_and_sound(FrameRate::FPS_25), true); + let omf_file = omf_bytes(&sound_only(Some(pcm_ramp(100, 2, 1)))); + for (bytes, is_aaf) in [(&aaf_file, true), (&omf_file, false)] { + let read = |b: &[u8]| { + let mut report = Report::default(); + let _ = if is_aaf { aaf::read(b, &mut report) } else { omf::read(b, &mut report) }; + }; + for cut in (0..bytes.len()).step_by(97) { + read(&bytes[..cut]); + } + 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; + } + read(&g); + } + } +} + +/// A time that is not on a whole edit unit is rounded, and the report says so. +#[test] +fn off_grid_times_are_rounded_and_reported() { + let mut doc = picture_and_sound(FrameRate::FPS_25); + if let Some(CItem::Clip(c)) = doc.compositions[0].tracks[0].items.first_mut() { + c.start += Tick(7); + } + let mut report = Report::default(); + aaf::write(&doc, &aaf::WriteOptions { identity: IDENTITY, small_sectors: false }, &mut report).unwrap(); + assert!(report.mentions("rounded"), "{report}"); +} + +/// A composition that uses itself (a damaged file) is a gap with a warning, not a recursion. +#[test] +fn a_composition_nested_in_itself_is_a_gap() { + let rate = FrameRate::FPS_25; + let mut doc = picture_and_sound(rate); + let mut inner = doc.compositions[0].clone(); + inner.name = "Inner".into(); + let mut nest = doc.sources[0].clone(); + (nest.key, nest.path, nest.nested, nest.start_tc) = ("seq:9".into(), None, Some(0), None); + doc.sources.push(nest); + let uses_inner = CItem::Clip(crate::model::CClip { len: rate.tick_of(20), source: 2, start: Tick::ZERO, gain: None, name: "Inner".into() }); + inner.tracks[0].items = vec![uses_inner.clone()]; + doc.nested.push(inner); + doc.compositions[0].tracks[0].items = vec![uses_inner]; + let bytes = aaf::write(&doc, &aaf::WriteOptions { identity: IDENTITY, small_sectors: false }, &mut Report::default()).unwrap(); + let mut report = Report::default(); + let back = aaf::read(&bytes, &mut report).unwrap(); + assert!(report.mentions("nested in itself"), "{report}"); + assert_eq!(back.nested.len(), 1); + assert!(matches!(back.nested[0].tracks[0].items[..], [CItem::Filler(_)]), "{:?}", back.nested[0].tracks[0].items); +} diff --git a/crates/aaf/src/testdoc.rs b/crates/aaf/src/testdoc.rs new file mode 100644 index 00000000..81db0a15 --- /dev/null +++ b/crates/aaf/src/testdoc.rs @@ -0,0 +1,106 @@ +//! Documents the tests write: what an editor's two-clip sequence with a dissolve looks like in +//! the model. + +use craft_timeline::time::{FrameRate, Tick}; + +use crate::Identity; +use crate::model::{CClip, CItem, CKind, CTrack, CTransition, Composition, Document, Gain, Source}; + +pub(crate) const IDENTITY: Identity<'static> = + Identity { company: "Craft", product: "craft-aaf tests", version: "1.2.3", platform: "test", product_id: *b"craft-aaf-tests." }; + +/// A source of `kind` for the media item `key` at `path`: 1280×720 picture at `rate`, or stereo +/// 48 kHz sound of `bits` bits; 100 seconds long. +pub(crate) fn source(key: &str, path: &str, kind: CKind, rate: FrameRate, bits: u16, start_tc: Option) -> Source { + let sound = kind == CKind::Sound; + Source { + key: key.into(), + name: path.rsplit('/').next().unwrap_or(path).into(), + kind, + path: Some(path.into()), + channel: None, + channels: if sound { 2 } else { 0 }, + file_channels: if sound { 2 } else { 0 }, + width: if sound { 0 } else { 1280 }, + height: if sound { 0 } else { 720 }, + frame_rate: rate, + sample_rate: 48_000, + bits, + length: rate.tick_of(100 * rate.timecode_base()), + offset: Tick::ZERO, + start_tc, + tc_rate: rate, + embedded: None, + markers: Vec::new(), + nested: None, + } +} + +pub(crate) fn clip(rate: FrameRate, source: usize, start: i64, len: i64, name: &str) -> CItem { + CItem::Clip(CClip { len: rate.tick_of(len), source, start: rate.tick_of(start), gain: None, name: name.into() }) +} + +/// Two clips of `source` cut at frame `cut`, each `cut` frames long on the timeline, joined by a +/// transition of `dur` frames centred on the cut: each clip runs half the transition past the cut. +pub(crate) fn two_clips(rate: FrameRate, source: usize, cut: i64, dur: i64, (in_a, in_b): (i64, i64), effect: &str) -> Vec { + let half = dur / 2; + vec![ + clip(rate, source, in_a, cut + half, "a"), + CItem::Transition(CTransition { len: rate.tick_of(dur), cut: rate.tick_of(half), effect: effect.into() }), + clip(rate, source, in_b - half, cut + half, "a"), + ] +} + +pub(crate) fn composition(name: &str, rate: FrameRate, tracks: Vec) -> Composition { + Composition { name: name.into(), rate, sample_rate: 48_000, width: 1920, height: 1080, start_tc: 0, drop: false, tracks, markers: Vec::new() } +} + +pub(crate) fn track(kind: CKind, name: &str, number: u32, items: Vec) -> CTrack { + CTrack { kind, name: name.into(), number, channels: if kind == CKind::Sound { 2 } else { 0 }, items } +} + +/// "S" at `rate`: V1 and A1 each hold two clips of `a.mov`, `cut` frames each on the timeline (from +/// source frames `ins`), with a transition of `dur` frames centred on the cut; the second sound +/// clip is 3 dB down. +pub(crate) fn picture_and_sound_cut(rate: FrameRate, cut: i64, dur: i64, ins: (i64, i64)) -> Document { + let sources = vec![ + source("item:1", "/m/a.mov", CKind::Picture, rate, 24, Some(86_400)), + source("item:1", "/m/a.mov", CKind::Sound, rate, 24, Some(86_400)), + ]; + let mut sound = two_clips(rate, 1, cut, dur, ins, "constant_power"); + if let Some(CItem::Clip(c)) = sound.last_mut() { + c.gain = Some(Gain::Constant(10f64.powf(-3.0 / 20.0))); + } + let tracks = vec![track(CKind::Picture, "V1", 1, two_clips(rate, 0, cut, dur, ins, "cross_dissolve")), track(CKind::Sound, "A1", 1, sound)]; + Document { compositions: vec![composition("S", rate, tracks)], sources, nested: Vec::new() } +} + +/// [`picture_and_sound_cut`] with 50-frame clips from source frames 10 and 205 and a 10-frame +/// transition: every time is a multiple of five frames, which is a whole number of 48 kHz samples +/// at the NTSC rates too. +pub(crate) fn picture_and_sound(rate: FrameRate) -> Document { + picture_and_sound_cut(rate, 50, 10, (10, 205)) +} + +/// 16-bit PCM: `frames` frames of `channels` channels, every sample different from its neighbours. +pub(crate) fn pcm_ramp(frames: usize, channels: usize, seed: i32) -> Vec { + (0..frames * channels).flat_map(|i| ((i as i32 * 37 + seed * 1000) as i16).to_le_bytes()).collect() +} + +/// "S" at 25 fps, sound only: A1 holds two 50-frame clips of `a.wav` (from source frames 10 and +/// 300) with a 10-frame crossfade centred on the cut; the first clip is 6 dB up. The sound is +/// embedded when `embedded` is given (480 frames of 16-bit stereo). +pub(crate) fn sound_only(embedded: Option>) -> Document { + let rate = FrameRate::FPS_25; + let mut s = source("item:1", "/m/a.wav", CKind::Sound, rate, 16, None); + if let Some(pcm) = embedded { + s.path = None; + s.length = Tick::from_units((pcm.len() / 4) as i64, 48_000); + s.embedded = Some(pcm); + } + let mut items = two_clips(rate, 0, 50, 10, (10, 300), "constant_power"); + if let Some(CItem::Clip(c)) = items.first_mut() { + c.gain = Some(Gain::Constant(10f64.powf(6.0 / 20.0))); + } + Document { compositions: vec![composition("S", rate, vec![track(CKind::Sound, "A1", 1, items)])], sources: vec![s], nested: Vec::new() } +}