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() } +}