M11.4: AAF (Edit Protocol) and OMF 2.0 import/export in filmcraft-interchange

Shared mob-style composition model; AAF objects in structured storage over
filmcraft-cfb; OMF objects in a Bento container; embedded / consolidated audio
supplied by the caller (essence::audio_needs, MediaOptions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:23:20 -04:00
parent 02a7039ac5
commit 0658967a5b
18 changed files with 5382 additions and 2 deletions

1
Cargo.lock generated
View File

@ -1972,6 +1972,7 @@ dependencies = [
name = "filmcraft-interchange"
version = "0.1.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
"filmcraft-geom",
"filmcraft-media",

View File

@ -15,6 +15,7 @@ filmcraft-geom = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-cfb = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -0,0 +1,253 @@
//! Class ids, property ids and definition ids of the AAF object model subset FilmCraft reads and
//! writes (AAF Object Specification v1.1, AAF Edit Protocol). Class and data definition AUIDs are
//! SMPTE labels (RP 224 / RP 210 registers) in AUID byte order; property ids are the AAF / ST 377-1
//! local tags.
use super::store::Auid;
/// AUID of a SMPTE universal label: the label's last 8 bytes become Data1–Data3 (stored
/// little-endian), its first 8 bytes Data4.
pub(crate) const fn ul(b: [u8; 16]) -> Auid {
[b[11], b[10], b[9], b[8], b[13], b[12], b[15], b[14], b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]
}
/// A GUID `{d1-d2-d3-d4}` in AUID byte order.
pub(crate) const fn guid(d1: u32, d2: u16, d3: u16, d4: [u8; 8]) -> Auid {
let a = d1.to_le_bytes();
let b = d2.to_le_bytes();
let c = d3.to_le_bytes();
[a[0], a[1], a[2], a[3], b[0], b[1], c[0], c[1], d4[0], d4[1], d4[2], d4[3], d4[4], d4[5], d4[6], d4[7]]
}
/// Class AUID: SMPTE label 06.0E.2B.34.02.06.01.01.0D.01.01.01.01.01.xx.00.
pub(crate) const fn class(xx: u8) -> Auid {
ul([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x06, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, xx, 0x00])
}
/// The root object's class (the storage class of the file's root).
pub(crate) const ROOT: Auid = guid(0xB3B398A5, 0x1C90, 0x11D4, [0x80, 0x53, 0x08, 0x00, 0x36, 0x21, 0x08, 0x04]);
/// The meta-dictionary (class 0x25 of the meta-model, 06.0E.2B.34.02.06.01.01.0D.01.01.01.02.25.00.00).
pub(crate) const META_DICTIONARY: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x02, 0x06, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x02, 0x25, 0x00, 0x00]);
#[allow(dead_code)]
pub(crate) mod cls {
use super::class;
use crate::aaf::store::Auid;
pub const COMMENT_MARKER: Auid = class(0x08);
pub const FILLER: Auid = class(0x09);
pub const OPERATION_GROUP: Auid = class(0x0A);
pub const NESTED_SCOPE: Auid = class(0x0B);
pub const SELECTOR: Auid = class(0x0E);
pub const SEQUENCE: Auid = class(0x0F);
pub const SOURCE_CLIP: Auid = class(0x11);
pub const TIMECODE: Auid = class(0x14);
pub const TRANSITION: Auid = class(0x17);
pub const CONTENT_STORAGE: Auid = class(0x18);
pub const CONTROL_POINT: Auid = class(0x19);
pub const DATA_DEFINITION: Auid = class(0x1B);
pub const OPERATION_DEFINITION: Auid = class(0x1C);
pub const PARAMETER_DEFINITION: Auid = class(0x1D);
pub const CONTAINER_DEFINITION: Auid = class(0x20);
pub const INTERPOLATION_DEFINITION: Auid = class(0x21);
pub const DICTIONARY: Auid = class(0x22);
pub const ESSENCE_DATA: Auid = class(0x23);
pub const CDCI_DESCRIPTOR: Auid = class(0x28);
pub const TAPE_DESCRIPTOR: Auid = class(0x2E);
pub const HEADER: Auid = class(0x2F);
pub const IDENTIFICATION: Auid = class(0x30);
pub const NETWORK_LOCATOR: Auid = class(0x32);
pub const COMPOSITION_MOB: Auid = class(0x35);
pub const MASTER_MOB: Auid = class(0x36);
pub const SOURCE_MOB: Auid = class(0x37);
pub const EVENT_MOB_SLOT: Auid = class(0x39);
pub const TIMELINE_MOB_SLOT: Auid = class(0x3B);
pub const CONSTANT_VALUE: Auid = class(0x3D);
pub const VARYING_VALUE: Auid = class(0x3E);
pub const TAGGED_VALUE: Auid = class(0x3F);
pub const DESCRIPTIVE_MARKER: Auid = class(0x41);
pub const PCM_DESCRIPTOR: Auid = class(0x48);
pub const IMPORT_DESCRIPTOR: Auid = class(0x4A);
/// Classes with a `Locator` and media properties FilmCraft reads as file descriptors.
pub const WAVE_DESCRIPTOR: Auid = class(0x2C);
pub const AIFC_DESCRIPTOR: Auid = class(0x26);
pub const SOUND_DESCRIPTOR: Auid = class(0x42);
pub const RGBA_DESCRIPTOR: Auid = class(0x29);
pub const MULTIPLE_DESCRIPTOR: Auid = class(0x44);
}
/// Property ids.
#[allow(dead_code)]
pub(crate) mod pid {
// Root
pub const ROOT_META_DICTIONARY: u16 = 0x0001;
pub const ROOT_HEADER: u16 = 0x0002;
// Header
pub const BYTE_ORDER: u16 = 0x3B01;
pub const LAST_MODIFIED: u16 = 0x3B02;
pub const CONTENT: u16 = 0x3B03;
pub const DICTIONARY: u16 = 0x3B04;
pub const VERSION: u16 = 0x3B05;
pub const IDENTIFICATION_LIST: u16 = 0x3B06;
pub const OBJECT_MODEL_VERSION: u16 = 0x3B07;
pub const OPERATIONAL_PATTERN: u16 = 0x3B09;
// Identification
pub const COMPANY_NAME: u16 = 0x3C01;
pub const PRODUCT_NAME: u16 = 0x3C02;
pub const PRODUCT_VERSION_STRING: u16 = 0x3C04;
pub const PRODUCT_ID: u16 = 0x3C05;
pub const DATE: u16 = 0x3C06;
pub const PLATFORM: u16 = 0x3C08;
pub const GENERATION_AUID: u16 = 0x3C09;
// ContentStorage
pub const MOBS: u16 = 0x1901;
pub const ESSENCE_DATA: u16 = 0x1902;
// Dictionary
pub const OPERATION_DEFINITIONS: u16 = 0x2603;
pub const PARAMETER_DEFINITIONS: u16 = 0x2604;
pub const DATA_DEFINITIONS: u16 = 0x2605;
pub const CONTAINER_DEFINITIONS: u16 = 0x2608;
pub const INTERPOLATION_DEFINITIONS: u16 = 0x2609;
// DefinitionObject
pub const IDENTIFICATION: u16 = 0x1B01;
pub const NAME: u16 = 0x1B02;
pub const DESCRIPTION: u16 = 0x1B03;
// OperationDefinition
pub const OPDEF_DATA_DEFINITION: u16 = 0x1E01;
pub const OPDEF_IS_TIME_WARP: u16 = 0x1E02;
pub const OPDEF_NUMBER_INPUTS: u16 = 0x1E07;
pub const OPDEF_PARAMETERS_DEFINED: u16 = 0x1E09;
// ParameterDefinition
pub const PARAMDEF_DISPLAY_UNITS: u16 = 0x1F03;
// Mob
pub const MOB_ID: u16 = 0x4401;
pub const MOB_NAME: u16 = 0x4402;
pub const SLOTS: u16 = 0x4403;
pub const MOB_LAST_MODIFIED: u16 = 0x4404;
pub const MOB_CREATION_TIME: u16 = 0x4405;
pub const MOB_USER_COMMENTS: u16 = 0x4406;
pub const USAGE_CODE: u16 = 0x4408;
// SourceMob
pub const ESSENCE_DESCRIPTION: u16 = 0x4701;
// MobSlot
pub const SLOT_ID: u16 = 0x4801;
pub const SLOT_NAME: u16 = 0x4802;
pub const SEGMENT: u16 = 0x4803;
pub const PHYSICAL_TRACK_NUMBER: u16 = 0x4804;
// TimelineMobSlot / EventMobSlot
pub const EDIT_RATE: u16 = 0x4B01;
pub const ORIGIN: u16 = 0x4B02;
pub const EVENT_EDIT_RATE: u16 = 0x4901;
// Component
pub const DATA_DEFINITION: u16 = 0x0201;
pub const LENGTH: u16 = 0x0202;
pub const COMPONENT_USER_COMMENTS: u16 = 0x0204;
// Sequence
pub const COMPONENTS: u16 = 0x1001;
// SourceReference / SourceClip
pub const SOURCE_ID: u16 = 0x1101;
pub const SOURCE_MOB_SLOT_ID: u16 = 0x1102;
pub const START_TIME: u16 = 0x1201;
// Event
pub const POSITION: u16 = 0x0601;
pub const COMMENT: u16 = 0x0602;
// Timecode
pub const TC_START: u16 = 0x1501;
pub const TC_FPS: u16 = 0x1502;
pub const TC_DROP: u16 = 0x1503;
// Transition
pub const OPERATION_GROUP: u16 = 0x1801;
pub const CUT_POINT: u16 = 0x1802;
// OperationGroup
pub const OPERATION: u16 = 0x0B01;
pub const INPUT_SEGMENTS: u16 = 0x0B02;
pub const PARAMETERS: u16 = 0x0B03;
// Selector / NestedScope
pub const SELECTED: u16 = 0x0F01;
pub const NESTED_SLOTS: u16 = 0x0C01;
// Parameter / ConstantValue / VaryingValue / ControlPoint
pub const PARAMETER_DEFINITION: u16 = 0x4C01;
pub const CONSTANT_VALUE: u16 = 0x4D01;
pub const INTERPOLATION: u16 = 0x4E01;
pub const POINT_LIST: u16 = 0x4E02;
pub const CP_VALUE: u16 = 0x1A02;
pub const CP_TIME: u16 = 0x1A03;
pub const CP_EDIT_HINT: u16 = 0x1A04;
// TaggedValue
pub const TAG_NAME: u16 = 0x5001;
pub const TAG_VALUE: u16 = 0x5003;
// EssenceDescriptor / FileDescriptor
pub const LOCATOR: u16 = 0x2F01;
pub const SAMPLE_RATE: u16 = 0x3001;
pub const FILE_LENGTH: u16 = 0x3002;
pub const CONTAINER_FORMAT: u16 = 0x3004;
pub const STORED_HEIGHT: u16 = 0x3202;
pub const STORED_WIDTH: u16 = 0x3203;
pub const FRAME_LAYOUT: u16 = 0x320C;
pub const VIDEO_LINE_MAP: u16 = 0x320D;
pub const IMAGE_ASPECT_RATIO: u16 = 0x320E;
pub const COMPONENT_WIDTH: u16 = 0x3301;
pub const HORIZONTAL_SUBSAMPLING: u16 = 0x3302;
pub const QUANTIZATION_BITS: u16 = 0x3D01;
pub const LOCKED: u16 = 0x3D02;
pub const AUDIO_SAMPLING_RATE: u16 = 0x3D03;
pub const CHANNELS: u16 = 0x3D07;
pub const AVERAGE_BPS: u16 = 0x3D09;
pub const BLOCK_ALIGN: u16 = 0x3D0A;
// NetworkLocator
pub const URL_STRING: u16 = 0x4001;
// EssenceData
pub const ESSENCE_MOB_ID: u16 = 0x2701;
pub const ESSENCE_STREAM: u16 = 0x2702;
}
/// Data definitions (SMPTE RP 224 labels 06.0E.2B.34.04.01.01.01.01.03.02.xx.yy).
pub(crate) mod ddef {
use super::{guid, ul};
use crate::aaf::store::Auid;
pub const PICTURE: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00]);
pub const SOUND: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00]);
pub const TIMECODE: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00]);
pub const DESCRIPTIVE_METADATA: Auid = ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x10, 0x00, 0x00, 0x00]);
/// Pre-SMPTE ("legacy") data definitions written by older applications.
pub const LEGACY_PICTURE: Auid = guid(0x6F3C8CE1, 0x6CEF, 0x11D2, [0x80, 0x7D, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
pub const LEGACY_SOUND: Auid = guid(0x78E1EBE1, 0x6CEF, 0x11D2, [0x80, 0x7D, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
pub const LEGACY_TIMECODE: Auid = guid(0x7F275E81, 0x77E5, 0x11D2, [0x80, 0x7F, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F]);
}
/// Operation, parameter, interpolation and container definitions.
pub(crate) mod def {
use super::guid;
use crate::aaf::store::Auid;
const AV: [u8; 8] = [0x8A, 0x09, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
pub const VIDEO_DISSOLVE: Auid = guid(0x0C3BEA41, 0xFC05, 0x11D2, AV);
pub const VIDEO_FADE_TO_BLACK: Auid = guid(0x0C3BEA40, 0xFC05, 0x11D2, AV);
pub const SMPTE_VIDEO_WIPE: Auid = guid(0x0C3BEA43, 0xFC05, 0x11D2, AV);
pub const MONO_AUDIO_DISSOLVE: Auid = guid(0x0C3BEA44, 0xFC05, 0x11D2, AV);
const AG: [u8; 8] = [0x8A, 0x38, 0x00, 0x50, 0x04, 0x0E, 0xF7, 0xD2];
pub const MONO_AUDIO_GAIN: Auid = guid(0x9D2EA893, 0x0968, 0x11D3, AG);
const PD: [u8; 8] = [0x8A, 0x4C, 0x00, 0x50, 0x04, 0x0E, 0xF7, 0xD2];
pub const PARAM_LEVEL: Auid = guid(0xE4962320, 0x2267, 0x11D3, PD);
pub const PARAM_AMPLITUDE: Auid = guid(0xE4962321, 0x2267, 0x11D3, PD);
const IP: [u8; 8] = [0x80, 0xA9, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
pub const INTERP_LINEAR: Auid = guid(0x5B6C85A4, 0x0EDE, 0x11D3, IP);
pub const INTERP_CONSTANT: Auid = guid(0x5B6C85A5, 0x0EDE, 0x11D3, IP);
const CD: [u8; 8] = [0x80, 0x9B, 0x00, 0x60, 0x08, 0x14, 0x3E, 0x6F];
pub const CONTAINER_AAF: Auid = guid(0x4313B571, 0xD8BA, 0x11D2, CD);
pub const CONTAINER_EXTERNAL: Auid = guid(0x4313B572, 0xD8BA, 0x11D2, CD);
/// Type ids of indirect values (SMPTE RP 210 type labels).
pub const TYPE_RATIONAL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x01, 0x04, 0x01, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
pub const TYPE_STRING: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x01, 0x04, 0x01, 0x01, 0x01, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]);
/// Edit Protocol operational pattern and mob usage codes.
pub const OP_EDIT_PROTOCOL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x05, 0x0D, 0x01, 0x12, 0x01, 0x01, 0x00, 0x00, 0x00]);
pub const USAGE_TOP_LEVEL: Auid = super::ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x05, 0x0D, 0x01, 0x12, 0x01, 0x02, 0x03, 0x00, 0x00]);
}
/// Weak reference target paths (pids from the root object).
pub(crate) mod path {
use super::pid;
pub const DATA_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::DATA_DEFINITIONS];
pub const OPERATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::OPERATION_DEFINITIONS];
pub const PARAMETER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::PARAMETER_DEFINITIONS];
pub const INTERPOLATION_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::INTERPOLATION_DEFINITIONS];
pub const CONTAINER_DEFS: [u16; 3] = [pid::ROOT_HEADER, pid::DICTIONARY, pid::CONTAINER_DEFINITIONS];
}

View File

@ -0,0 +1,84 @@
//! AAF (Advanced Authoring Format) import and export, following the AAF Edit Protocol.
//!
//! Written from the public AMWA specifications: *AAF Object Specification v1.1*, *AAF Low-Level
//! Container Specification v1.0.1* (structured storage mapping, see [`store`]) and *AAF Edit
//! Protocol v1.0*; the container is Microsoft's Compound File Binary format ([`filmcraft_cfb`]).
//!
//! The exported file holds one top-level **CompositionMob** (Edit Protocol usage code
//! "top level") with
//!
//! - a timecode slot (sequence start timecode, rounded rate, drop frame),
//! - one timeline slot per video track (picture) and per audio track (sound; one per channel when
//! broken out to mono), each a **Sequence** of **Filler**s, **SourceClip**s and **Transition**s
//! (Cross Dissolve → `VideoDissolve_2`, Dip to Black → `VideoFadeToBlack`, audio crossfades →
//! `MonoAudioDissolve`; the FilmCraft effect id is kept in a tagged value),
//! - clip volume as a `MonoAudioGain` **OperationGroup** around the source clip with an
//! `Amplitude` **ConstantValue** or **VaryingValue** (keyframes as control points),
//! - sequence markers as **CommentMarker**s in an event slot;
//!
//! and for every media item a **MasterMob**, a file **SourceMob** per essence (a CDCI or PCM
//! descriptor with a network locator to the file, or `EssenceData` holding embedded PCM) and a
//! tape **SourceMob** carrying the media's start timecode. Clip and media markers become comment
//! markers on the master mob.
//!
//! Import reads the same structures (and the common variations: nested sequences, selectors,
//! operation groups around clips, legacy data definitions, essence groups) into FilmCraft
//! sequences, media items (linked by path; embedded audio is returned as WAV files for the
//! caller to write) and markers.
mod ids;
mod read;
pub(crate) mod store;
mod write;
use filmcraft_project::{ItemId, Project};
use serde::{Deserialize, Serialize};
use crate::comp::{self, ExtractedMedia};
use crate::essence::MediaOptions;
use crate::{ImportOptions, Imported, Report, Result};
/// AAF export options (Premiere's AAF export dialog maps onto [`MediaOptions`]).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AafOptions {
/// Composition name (default: the sequence name).
pub name: Option<String>,
pub media: MediaOptions,
/// Write 512-byte sectors (compound file version 3) instead of 4096-byte sectors.
pub small_sectors: bool,
}
/// Export `sequence` as an AAF file.
pub fn export(project: &Project, sequence: ItemId, opts: &AafOptions) -> Result<(Vec<u8>, Report)> {
let mut report = Report::default();
let name = opts.name.clone().or_else(|| project.item(sequence).map(|i| i.name.clone())).unwrap_or_else(|| "Sequence".into());
let doc = comp::from_project(project, sequence, &name, &opts.media, &mut report)?;
let version = if opts.small_sectors { filmcraft_cfb::Version::V3 } else { filmcraft_cfb::Version::V4 };
let bytes = write::write(&doc, version, &mut report)?;
Ok((bytes, report))
}
/// Import an AAF file: its top-level compositions become sequences. Embedded audio is returned
/// as WAV files whose paths are the paths of the imported media items.
pub fn import(bytes: &[u8], opts: &ImportOptions) -> Result<(Imported, Vec<ExtractedMedia>, Report)> {
let mut report = Report::default();
let doc = read::read(bytes, &mut report)?;
let (imported, extracted) = comp::to_project(doc, opts, &mut report)?;
Ok((imported, extracted, report))
}
/// Whether `bytes` look like an AAF file (a compound file whose root has the AAF root class or a
/// `Header-2` storage).
pub fn sniff(bytes: &[u8]) -> bool {
if !filmcraft_cfb::sniff(bytes) {
return false;
}
match filmcraft_cfb::CompoundFile::open(bytes) {
Ok(cf) => cf.root().clsid == ids::ROOT || cf.find("Header-2").is_ok(),
Err(_) => false,
}
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,575 @@
//! AAF objects → composition model.
use std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, Tick};
use super::ids::{cls, ddef, def, pid};
use super::store::{self, Auid, Obj, Value, rational_of, utf16_of};
use crate::comp::{CClip, CItem, CKind, CMarker, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks};
use crate::{Error, Report, Result};
type MobId = [u8; 32];
#[derive(Clone, Copy, PartialEq, Eq)]
enum DataKind {
Picture,
Sound,
Timecode,
Other,
}
fn data_kind(o: &Obj) -> DataKind {
match o.weak_key(pid::DATA_DEFINITION).and_then(|k| <Auid>::try_from(k).ok()) {
Some(k) if k == ddef::PICTURE || k == ddef::LEGACY_PICTURE => DataKind::Picture,
Some(k) if k == ddef::SOUND || k == ddef::LEGACY_SOUND => DataKind::Sound,
Some(k) if k == ddef::TIMECODE || k == ddef::LEGACY_TIMECODE => DataKind::Timecode,
Some(_) => DataKind::Other,
None => DataKind::Other,
}
}
fn len_of(o: &Obj) -> i64 {
o.i64(pid::LENGTH).unwrap_or(0).max(0)
}
/// The value of an indirect property: (type id, bytes).
fn indirect(d: &[u8]) -> Option<(Auid, &[u8])> {
if d.len() >= 17 && (d[0] == 0x4C || d[0] == 0x42) {
return Some((d[1..17].try_into().ok()?, &d[17..]));
}
if d.len() >= 16 {
return Some((d[..16].try_into().ok()?, &d[16..]));
}
None
}
fn tagged_values(o: &Obj, p: u16) -> Vec<(String, String)> {
o.objs(p)
.iter()
.filter_map(|t| {
let name = t.string(pid::TAG_NAME)?;
let (ty, v) = indirect(t.data(pid::TAG_VALUE)?)?;
let value = if ty == def::TYPE_STRING || v.len() % 2 == 0 { utf16_of(v) } else { String::new() };
Some((name, value))
})
.collect()
}
fn indirect_f64(d: &[u8]) -> Option<f64> {
let (_, v) = indirect(d)?;
match v.len() {
8 => rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64),
4 => Some(i32::from_le_bytes(v.try_into().ok()?) as f64),
_ => None,
}
}
struct R<'a> {
report: &'a mut Report,
mobs: HashMap<MobId, &'a Obj>,
essence: HashMap<MobId, &'a [u8]>,
doc: Document,
/// (file mob id, slot id, master key) → source index
sources: HashMap<(MobId, u32, String), usize>,
}
pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
let root = store::read(bytes).map_err(|e| Error::Parse { format: "AAF", message: e })?;
let header = root.strong(pid::ROOT_HEADER).ok_or_else(|| Error::Parse { format: "AAF", message: "no header object".into() })?;
let content = header.strong(pid::CONTENT).ok_or_else(|| Error::Parse { format: "AAF", message: "no content storage".into() })?;
let mut mobs = HashMap::new();
for m in content.objs(pid::MOBS) {
if let Some(id) = m.mob_id(pid::MOB_ID) {
mobs.insert(id, m);
}
}
let mut essence = HashMap::new();
for e in content.objs(pid::ESSENCE_DATA) {
if let (Some(id), Some(d)) = (e.mob_id(pid::ESSENCE_MOB_ID), e.stream(pid::ESSENCE_STREAM)) {
essence.insert(id, d);
}
}
let mut r = R { report, mobs, essence, doc: Document::default(), sources: HashMap::new() };
// top-level compositions: not referenced by another composition (usage code wins when present)
let comps: Vec<&Obj> = content.objs(pid::MOBS).iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect();
let mut referenced = HashSet::new();
for c in &comps {
collect_refs(c, &mut referenced);
}
let tagged_top: Vec<&Obj> = comps.iter().copied().filter(|c| c.auid(pid::USAGE_CODE) == Some(def::USAGE_TOP_LEVEL)).collect();
let top: Vec<&Obj> = if !tagged_top.is_empty() {
tagged_top
} else {
comps.iter().copied().filter(|c| c.mob_id(pid::MOB_ID).is_none_or(|id| !referenced.contains(&id))).collect()
};
for c in top {
let comp = r.composition(c);
r.doc.compositions.push(comp);
}
if r.doc.compositions.is_empty() {
return Err(Error::Empty);
}
Ok(r.doc)
}
fn collect_refs(o: &Obj, out: &mut HashSet<MobId>) {
if o.class == cls::SOURCE_CLIP
&& let Some(id) = o.mob_id(pid::SOURCE_ID)
{
out.insert(id);
}
for (_, v) in &o.props {
match v {
Value::Strong(c) => collect_refs(c, out),
Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| collect_refs(c, out)),
_ => {}
}
}
}
fn rate_of(o: &Obj, p: u16) -> Option<(i64, i64)> {
o.rational(p).filter(|r| r.0 > 0 && r.1 > 0)
}
impl R<'_> {
fn composition(&mut self, c: &Obj) -> Composition {
let name = c.string(pid::MOB_NAME).unwrap_or_else(|| "Sequence".into());
let slots = c.objs(pid::SLOTS);
let pic_rate = slots
.iter()
.filter(|s| s.class == cls::TIMELINE_MOB_SLOT)
.find(|s| s.strong(pid::SEGMENT).is_some_and(|g| matches!(data_kind(g), DataKind::Picture | DataKind::Timecode)))
.and_then(|s| rate_of(s, pid::EDIT_RATE))
.or_else(|| slots.iter().find_map(|s| rate_of(s, pid::EDIT_RATE)))
.unwrap_or((25, 1));
let rate = FrameRate::new(pic_rate.0, pic_rate.1);
let tags = tagged_values(c, pid::MOB_USER_COMMENTS);
let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
let (mut width, mut height) = tag("FilmCraft Frame Size")
.and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?))))
.unwrap_or((0, 0));
let mut sample_rate = tag("FilmCraft Audio Sample Rate").and_then(|s| s.parse().ok()).unwrap_or(0u32);
let mut comp =
Composition { name, rate, sample_rate: 48_000, width: 1920, height: 1080, start_tc: 0, drop: false, tracks: Vec::new(), markers: Vec::new() };
let (mut nv, mut na) = (0, 0);
for s in slots {
let Some(seg) = s.strong(pid::SEGMENT) else { continue };
if s.class == cls::EVENT_MOB_SLOT {
let er = rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(pic_rate);
comp.markers.extend(markers_of(seg, er));
continue;
}
let er = rate_of(s, pid::EDIT_RATE).unwrap_or(pic_rate);
let origin = s.i64(pid::ORIGIN).unwrap_or(0);
match data_kind(seg) {
DataKind::Timecode => {
if let Some(tc) = find_class(seg, cls::TIMECODE) {
comp.start_tc = tc.i64(pid::TC_START).unwrap_or(0);
comp.drop = tc.u8(pid::TC_DROP).unwrap_or(0) != 0;
}
}
kind @ (DataKind::Picture | DataKind::Sound) => {
let ckind = if kind == DataKind::Picture { CKind::Picture } else { CKind::Sound };
if ckind == CKind::Sound && sample_rate == 0 && er.1 == 1 && er.0 >= 8000 {
sample_rate = er.0 as u32;
}
let mut items = self.segment(seg, er, ckind, 0);
if origin > 0 {
// material before the origin is not shown
trim_front(&mut items, units_to_ticks(origin, er.0, er.1));
}
let number = s.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(0);
let name = s.string(pid::SLOT_NAME).unwrap_or_default();
let channels = if ckind == CKind::Sound {
items
.iter()
.filter_map(|i| match i {
CItem::Clip(c) => Some(self.doc.sources[c.source].channels),
_ => None,
})
.max()
.unwrap_or(1)
.min(6)
} else {
0
};
let n = if ckind == CKind::Picture {
nv += 1;
nv
} else {
na += 1;
na
};
comp.tracks.push(CTrack { kind: ckind, name, number: if number > 0 { number } else { n }, channels, items });
}
DataKind::Other => self.report.info("a slot of an unsupported data kind was skipped"),
}
}
if width == 0 {
let pic = self.doc.sources.iter().find(|s| s.kind == CKind::Picture && s.width > 0);
(width, height) = pic.map_or((1920, 1080), |p| (p.width, p.height));
}
comp.width = width;
comp.height = height;
if sample_rate > 0 {
comp.sample_rate = sample_rate;
}
comp
}
/// Items of a segment (a sequence or a single component).
fn segment(&mut self, seg: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec<CItem> {
if depth > 32 {
self.report.warn("segments nested too deeply were skipped");
return vec![CItem::Filler(units_to_ticks(len_of(seg), rate.0, rate.1))];
}
if seg.class == cls::SEQUENCE {
let mut out = Vec::new();
for c in seg.objs(pid::COMPONENTS) {
out.extend(self.component(c, rate, kind, depth + 1));
}
return out;
}
self.component(seg, rate, kind, depth + 1)
}
fn component(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec<CItem> {
let len = units_to_ticks(len_of(c), rate.0, rate.1);
match c.class {
x if x == cls::FILLER => vec![CItem::Filler(len)],
x if x == cls::SOURCE_CLIP => vec![self.clip(c, rate, kind, None)],
x if x == cls::SEQUENCE => self.segment(c, rate, kind, depth),
x if x == cls::TRANSITION => {
let cut = units_to_ticks(c.i64(pid::CUT_POINT).unwrap_or(0), rate.0, rate.1);
let og = c.strong(pid::OPERATION_GROUP);
let effect = og
.and_then(|o| tagged_values(o, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "FilmCraft Effect").map(|(_, v)| v))
.unwrap_or_else(|| {
let op = og.and_then(|o| o.weak_key(pid::OPERATION)).and_then(|k| <Auid>::try_from(k).ok());
match (kind, op) {
(CKind::Sound, _) => "constant_power".into(),
(_, Some(o)) if o == def::VIDEO_FADE_TO_BLACK => "dip_to_black".into(),
(_, Some(o)) if o == def::SMPTE_VIDEO_WIPE => "wipe".into(),
_ => "cross_dissolve".into(),
}
});
vec![CItem::Transition(CTransition { len, cut: cut.clamp(Tick::ZERO, len), effect })]
}
x if x == cls::OPERATION_GROUP => {
let op = c.weak_key(pid::OPERATION).and_then(|k| <Auid>::try_from(k).ok());
let inputs = c.objs(pid::INPUT_SEGMENTS);
let Some(input) = inputs.first() else {
self.report.info("an effect without input was imported as a gap");
return vec![CItem::Filler(len)];
};
let gain = if op == Some(def::MONO_AUDIO_GAIN) {
self.gain(c, len, rate)
} else {
self.report.warn("effects other than audio gain are not imported (their first input is used)");
None
};
let mut items = self.segment(input, rate, kind, depth);
// the operation's length rules
fit(&mut items, len);
if let Some(g) = gain {
match items.as_mut_slice() {
[CItem::Clip(cl)] => cl.gain = Some(g),
_ => self.report.warn("audio gain around several clips was not imported"),
}
}
items
}
x if x == cls::SELECTOR => match c.strong(pid::SELECTED) {
Some(s) => {
let mut items = self.segment(s, rate, kind, depth);
fit(&mut items, len);
items
}
None => vec![CItem::Filler(len)],
},
x if x == cls::NESTED_SCOPE => match c.objs(pid::NESTED_SLOTS).last() {
Some(s) => {
let mut items = self.segment(s, rate, kind, depth);
fit(&mut items, len);
items
}
None => vec![CItem::Filler(len)],
},
_ => {
self.report.info("an unsupported component was imported as a gap");
vec![CItem::Filler(len)]
}
}
}
fn gain(&mut self, og: &Obj, len: Tick, _rate: (i64, i64)) -> Option<Gain> {
let p = og
.objs(pid::PARAMETERS)
.iter()
.find(|p| p.auid(pid::PARAMETER_DEFINITION) == Some(def::PARAM_AMPLITUDE))
.or_else(|| og.objs(pid::PARAMETERS).first())?;
if p.class == cls::CONSTANT_VALUE {
return p.data(pid::CONSTANT_VALUE).and_then(indirect_f64).map(Gain::Constant);
}
if p.class == cls::VARYING_VALUE {
let linear = p.weak_key(pid::INTERPOLATION).and_then(|k| <Auid>::try_from(k).ok()) != Some(def::INTERP_CONSTANT);
let points: Vec<(Tick, f64)> = p
.objs(pid::POINT_LIST)
.iter()
.filter_map(|cp| {
let (n, d) = cp.rational(pid::CP_TIME).filter(|r| r.1 != 0)?;
let v = cp.data(pid::CP_VALUE).and_then(indirect_f64)?;
Some((Tick((len.0 as i128 * n as i128 / d as i128).clamp(-(1i128 << 56), 1i128 << 56) as i64), v))
})
.collect();
return (!points.is_empty()).then_some(Gain::Varying { linear, points });
}
None
}
fn clip(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, master_key: Option<String>) -> CItem {
let len = units_to_ticks(len_of(c), rate.0, rate.1);
let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let name = tagged_values(c, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "Clip Name").map(|(_, v)| v).unwrap_or_default();
let (Some(id), Some(slot)) = (c.mob_id(pid::SOURCE_ID), c.u32(pid::SOURCE_MOB_SLOT_ID)) else { return CItem::Filler(len) };
if id == [0; 32] {
return CItem::Filler(len);
}
match self.resolve(id, slot, start, kind, master_key, 0) {
Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }),
None => CItem::Filler(len),
}
}
/// Follow a source reference to a file source mob: (source index, file time).
fn resolve(&mut self, id: MobId, slot_id: u32, start: Tick, kind: CKind, master_key: Option<String>, depth: usize) -> Option<(usize, Tick)> {
if depth > 16 {
return None;
}
let Some(mob) = self.mobs.get(&id).copied() else {
self.report.warn("a clip references a mob that is not in the file (imported as a gap)");
return None;
};
let slot = mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot_id))?;
let rate = rate_of(slot, pid::EDIT_RATE).unwrap_or((25, 1));
let seg = slot.strong(pid::SEGMENT)?;
if mob.class == cls::COMPOSITION_MOB {
self.report.warn("nested compositions are not supported (imported as gaps)");
return None;
}
if mob.class == cls::MASTER_MOB {
let key = master_key.unwrap_or_else(|| hex(&id));
let clip = first_clip(seg)?;
let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let next = clip.mob_id(pid::SOURCE_ID)?;
let next_slot = clip.u32(pid::SOURCE_MOB_SLOT_ID)?;
if next == [0; 32] {
return None;
}
let markers = mob
.objs(pid::SLOTS)
.iter()
.filter(|s| s.class == cls::EVENT_MOB_SLOT)
.flat_map(|s| s.strong(pid::SEGMENT).map(|g| markers_of(g, rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(rate))).unwrap_or_default())
.collect::<Vec<_>>();
let r = self.resolve(next, next_slot, start + inner, kind, Some(key), depth + 1)?;
let src = &mut self.doc.sources[r.0];
if src.markers.is_empty() {
src.markers = markers;
}
if src.name.is_empty()
&& let Some(n) = mob.string(pid::MOB_NAME)
{
src.name = n;
}
return Some(r);
}
// a source mob: file (has a file descriptor) or physical (tape / import)
let desc = mob.strong(pid::ESSENCE_DESCRIPTION);
let is_file = desc.is_some_and(|d| d.get(pid::SAMPLE_RATE).is_some() || d.get(pid::LOCATOR).is_some() || self.essence.contains_key(&id));
if !is_file {
return None;
}
let key = master_key.unwrap_or_else(|| hex(&id));
let skey = (id, slot_id, key.clone());
if let Some(&i) = self.sources.get(&skey) {
return Some((i, start));
}
let d = desc?;
let seg_kind = match data_kind(seg) {
DataKind::Picture => CKind::Picture,
DataKind::Sound => CKind::Sound,
_ => kind,
};
let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| crate::common::file_url_to_path(&u));
let file_rate = rate_of(d, pid::SAMPLE_RATE).unwrap_or(rate);
let length =
d.i64(pid::FILE_LENGTH).map(|l| units_to_ticks(l, file_rate.0, file_rate.1)).unwrap_or_else(|| units_to_ticks(len_of(seg), rate.0, rate.1));
let sound_slots = mob.objs(pid::SLOTS).iter().filter(|s| s.strong(pid::SEGMENT).is_some_and(|g| data_kind(g) == DataKind::Sound)).count();
let file_channels = d.u32(pid::CHANNELS).unwrap_or(1).max(1);
let channel = (seg_kind == CKind::Sound && file_channels > 1 && sound_slots > 1)
.then(|| slot.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(slot_id).saturating_sub(1).min(file_channels - 1));
let mut layout = d.u8(pid::FRAME_LAYOUT).unwrap_or(0);
if layout > 4 {
layout = 0;
}
let stored_h = d.u32(pid::STORED_HEIGHT).unwrap_or(0);
let fr = FrameRate::new(file_rate.0, file_rate.1);
// timecode from the physical source mob
let tc = first_clip(seg).and_then(|clip| {
let tid = clip.mob_id(pid::SOURCE_ID)?;
let tape = self.mobs.get(&tid)?;
let tslot = tape.objs(pid::SLOTS).iter().find_map(|s| {
let g = s.strong(pid::SEGMENT)?;
let t = find_class(g, cls::TIMECODE)?;
Some((t.i64(pid::TC_START).unwrap_or(0), t.u16(pid::TC_FPS).unwrap_or(25).max(1) as i64, rate_of(s, pid::EDIT_RATE)))
})?;
let off = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let tcr = tslot.2.map(|r| FrameRate::new(r.0, r.1)).unwrap_or_else(|| FrameRate::new(tslot.1, 1));
Some((tslot.0 + tcr.frame_at(off), tcr))
});
let mut s = Source {
key,
name: mob.string(pid::MOB_NAME).unwrap_or_default(),
kind: seg_kind,
path,
channel,
channels: if channel.is_some() { 1 } else { file_channels },
file_channels,
width: d.u32(pid::STORED_WIDTH).unwrap_or(0),
height: if matches!(layout, 1 | 3) { stored_h * 2 } else { stored_h },
frame_rate: if seg_kind == CKind::Picture { fr } else { FrameRate::FPS_25 },
sample_rate: d.rational(pid::AUDIO_SAMPLING_RATE).filter(|r| r.1 > 0 && r.0 > 0).map(|r| (r.0 / r.1) as u32).unwrap_or(file_rate.0 as u32),
bits: d.u32(pid::QUANTIZATION_BITS).unwrap_or(16).clamp(8, 32) as u16,
length,
offset: Tick::ZERO,
start_tc: tc.map(|t| t.0),
tc_rate: tc.map(|t| t.1).unwrap_or(fr),
embedded: None,
markers: Vec::new(),
};
if s.kind == CKind::Sound {
s.frame_rate = FrameRate::FPS_25;
if let Some(data) = self.essence.get(&id) {
s.embedded = Some(data.to_vec());
// embedded essence may be a whole WAVE / AIFF file
if let Some((pcm, ch, sr, bits)) = crate::wav::parse_wav(data) {
s.embedded = Some(pcm);
s.file_channels = ch as u32;
s.channels = if s.channel.is_some() { 1 } else { ch as u32 };
s.sample_rate = sr;
s.bits = bits;
}
}
}
if s.kind == CKind::Picture && s.width == 0 {
s.width = 1920;
s.height = 1080;
}
let i = self.doc.sources.len();
self.doc.sources.push(s);
self.sources.insert(skey, i);
Some((i, start))
}
}
/// Drop `t` from the front of a track's items (slot origin).
fn trim_front(items: &mut Vec<CItem>, mut t: Tick) {
while t > Tick::ZERO && !items.is_empty() {
let l = items[0].len();
if matches!(items[0], CItem::Transition(_)) {
items.remove(0);
continue;
}
if l <= t {
items.remove(0);
t -= l;
} else {
match &mut items[0] {
CItem::Filler(x) => *x -= t,
CItem::Clip(c) => {
c.len -= t;
c.start += t;
}
CItem::Transition(_) => {}
}
t = Tick::ZERO;
}
}
}
/// Make the items exactly `len` long (an operation group / selector is as long as itself).
fn fit(items: &mut Vec<CItem>, len: Tick) {
let total = Tick(items.iter().fold(0i64, |a, i| if matches!(i, CItem::Transition(_)) { a.saturating_sub(i.len().0) } else { a.saturating_add(i.len().0) }));
if total == len || items.is_empty() {
if items.is_empty() {
items.push(CItem::Filler(len));
}
return;
}
if total < len {
items.push(CItem::Filler(len - total));
return;
}
let mut excess = total - len;
while excess > Tick::ZERO {
match items.last_mut() {
Some(CItem::Filler(x)) | Some(CItem::Clip(CClip { len: x, .. })) if *x > excess => {
*x -= excess;
excess = Tick::ZERO;
}
Some(CItem::Transition(_)) => {
items.pop();
}
Some(other) => {
excess -= other.len();
items.pop();
}
None => break,
}
}
}
fn first_clip(seg: &Obj) -> Option<&Obj> {
if seg.class == cls::SOURCE_CLIP {
return Some(seg);
}
if seg.class == cls::SEQUENCE {
return seg.objs(pid::COMPONENTS).iter().find_map(first_clip);
}
// essence group (choices 0x0501) / operation group inputs
seg.objs(0x0501).iter().find_map(first_clip).or_else(|| seg.objs(pid::INPUT_SEGMENTS).iter().find_map(first_clip))
}
fn find_class(seg: &Obj, class: Auid) -> Option<&Obj> {
if seg.class == class {
return Some(seg);
}
seg.objs(pid::COMPONENTS).iter().find_map(|c| find_class(c, class))
}
fn markers_of(seg: &Obj, rate: (i64, i64)) -> Vec<CMarker> {
let events: Vec<&Obj> = if seg.class == cls::SEQUENCE { seg.objs(pid::COMPONENTS).iter().collect() } else { vec![seg] };
events
.into_iter()
.filter(|e| e.class == cls::COMMENT_MARKER || e.class == cls::DESCRIPTIVE_MARKER || e.get(pid::POSITION).is_some())
.map(|e| {
let tags = tagged_values(e, pid::COMPONENT_USER_COMMENTS);
let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
let comment = e.string(pid::COMMENT).unwrap_or_default();
let start = units_to_ticks(e.i64(pid::POSITION).unwrap_or(0), rate.0, rate.1);
CMarker {
start,
duration: units_to_ticks(e.i64(pid::LENGTH).unwrap_or(0).max(0), rate.0, rate.1),
name: tag("Name").unwrap_or_else(|| comment.clone()),
comment: tag("Comment").unwrap_or_else(|| if tags.is_empty() { String::new() } else { comment.clone() }),
color: tag("Color"),
}
})
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}

View File

@ -0,0 +1,487 @@
//! AAF objects in structured storage (the AAF Low-Level Container Specification): every object
//! is a storage whose class id is the object's class AUID, holding a `properties` stream; strong
//! references are child storages, collections have an index stream, weak references name a key
//! in a target collection through the root's `referenced properties` table.
//!
//! `properties` stream (little-endian):
//!
//! ```text
//! u8 byte order (0x4C 'L') u8 format version (0x20) u16 entry count
//! entry count × { u16 pid, u16 stored form, u16 value length } the values, in entry order
//! ```
//!
//! | stored form | value |
//! |---|---|
//! | 0x82 data | the property's bytes |
//! | 0x22 strong reference | UTF-16 name of the child storage |
//! | 0x32 strong reference vector | UTF-16 collection name; storages `name{key}`, stream `name index`: u32 count, u32 first free key, u32 last free key, count × u32 local key |
//! | 0x3A strong reference set | as a vector; the index adds u16 key pid, u8 key size, and each entry is u32 local key, u32 reference count, key bytes |
//! | 0x02 weak reference | u16 referenced-property tag, u16 key pid, u8 key size, key bytes |
//! | 0x12 / 0x1A weak reference vector / set | UTF-16 collection name; stream `name index`: u32 count, u16 tag, u16 key pid, u8 key size, count × key |
//! | 0x42 data stream | u8 byte order, UTF-16 name of the stream |
//!
//! `referenced properties` (root stream): u8 byte order, u16 path count, u32 pid count, then the
//! pids of each path (from the root object) terminated by a 0 pid.
use filmcraft_cfb::{CompoundFile, EntryKind, NodeId, Version, Writer};
pub(crate) type Auid = [u8; 16];
pub(crate) const SF_DATA: u16 = 0x82;
pub(crate) const SF_DATA_STREAM: u16 = 0x42;
pub(crate) const SF_STRONG: u16 = 0x22;
pub(crate) const SF_STRONG_VECTOR: u16 = 0x32;
pub(crate) const SF_STRONG_SET: u16 = 0x3A;
pub(crate) const SF_WEAK: u16 = 0x02;
pub(crate) const SF_WEAK_VECTOR: u16 = 0x12;
pub(crate) const SF_WEAK_SET: u16 = 0x1A;
const BYTE_ORDER_LE: u8 = 0x4C;
const FORMAT_VERSION: u8 = 0x20;
/// A property value.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Value {
Data(Vec<u8>),
Strong(Box<Obj>),
StrongVec(Vec<Obj>),
/// Objects keyed by the data property `key_pid` of each.
StrongSet(Vec<Obj>, u16),
/// (path of pids from the root to the target collection, key pid, key)
Weak(Vec<u16>, u16, Vec<u8>),
WeakVec(Vec<u16>, u16, Vec<Vec<u8>>),
Stream(Vec<u8>),
}
/// An AAF object: its class and properties.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Obj {
pub class: Auid,
pub props: Vec<(u16, Value)>,
}
impl Obj {
pub fn new(class: Auid) -> Obj {
Obj { class, props: Vec::new() }
}
pub fn set(&mut self, pid: u16, v: Value) -> &mut Obj {
match self.props.iter_mut().find(|(p, _)| *p == pid) {
Some(slot) => slot.1 = v,
None => self.props.push((pid, v)),
}
self
}
pub fn with(mut self, pid: u16, v: Value) -> Obj {
self.set(pid, v);
self
}
pub fn data_prop(mut self, pid: u16, d: Vec<u8>) -> Obj {
self.set(pid, Value::Data(d));
self
}
pub fn get(&self, pid: u16) -> Option<&Value> {
self.props.iter().find(|(p, _)| *p == pid).map(|(_, v)| v)
}
pub fn data(&self, pid: u16) -> Option<&[u8]> {
match self.get(pid)? {
Value::Data(d) => Some(d),
_ => None,
}
}
pub fn strong(&self, pid: u16) -> Option<&Obj> {
match self.get(pid)? {
Value::Strong(o) => Some(o),
_ => None,
}
}
/// Elements of a strong vector or set (empty when absent).
pub fn objs(&self, pid: u16) -> &[Obj] {
match self.get(pid) {
Some(Value::StrongVec(v)) | Some(Value::StrongSet(v, _)) => v,
_ => &[],
}
}
pub fn weak_key(&self, pid: u16) -> Option<&[u8]> {
match self.get(pid)? {
Value::Weak(_, _, k) => Some(k),
_ => None,
}
}
pub fn stream(&self, pid: u16) -> Option<&[u8]> {
match self.get(pid)? {
Value::Stream(d) => Some(d),
_ => None,
}
}
pub fn u8(&self, pid: u16) -> Option<u8> {
self.data(pid).and_then(|d| d.first().copied())
}
pub fn u16(&self, pid: u16) -> Option<u16> {
self.data(pid).filter(|d| d.len() >= 2).map(|d| u16::from_le_bytes([d[0], d[1]]))
}
pub fn u32(&self, pid: u16) -> Option<u32> {
self.data(pid).filter(|d| d.len() >= 4).map(|d| u32::from_le_bytes([d[0], d[1], d[2], d[3]]))
}
pub fn i64(&self, pid: u16) -> Option<i64> {
let d = self.data(pid)?;
match d.len() {
8.. => Some(i64::from_le_bytes(d[..8].try_into().ok()?)),
4..=7 => Some(i32::from_le_bytes(d[..4].try_into().ok()?) as i64),
_ => None,
}
}
pub fn rational(&self, pid: u16) -> Option<(i64, i64)> {
self.data(pid).and_then(rational_of)
}
pub fn string(&self, pid: u16) -> Option<String> {
self.data(pid).map(utf16_of)
}
pub fn auid(&self, pid: u16) -> Option<Auid> {
self.data(pid).and_then(|d| d.get(..16)).and_then(|d| d.try_into().ok())
}
pub fn mob_id(&self, pid: u16) -> Option<[u8; 32]> {
self.data(pid).and_then(|d| d.get(..32)).and_then(|d| d.try_into().ok())
}
}
pub(crate) fn rational_of(d: &[u8]) -> Option<(i64, i64)> {
(d.len() >= 8).then(|| (i32::from_le_bytes([d[0], d[1], d[2], d[3]]) as i64, i32::from_le_bytes([d[4], d[5], d[6], d[7]]) as i64))
}
pub(crate) fn rational(num: i64, den: i64) -> Vec<u8> {
let mut v = (num.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes().to_vec();
v.extend_from_slice(&(den.clamp(i32::MIN as i64, i32::MAX as i64) as i32).to_le_bytes());
v
}
/// UTF-16LE with a terminating NUL.
pub(crate) fn utf16z(s: &str) -> Vec<u8> {
let mut v: Vec<u8> = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
v.extend_from_slice(&[0, 0]);
v
}
pub(crate) fn utf16_of(d: &[u8]) -> String {
let u: Vec<u16> = d.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|&c| c != 0).collect();
String::from_utf16_lossy(&u)
}
/// Short property names used to name storages and streams (any name is valid: readers follow
/// the names stored in the property values).
fn prop_name(pid: u16) -> &'static str {
match pid {
0x0001 => "MetaDictionary",
0x0002 => "Header",
0x3B03 => "Content",
0x3B04 => "Dictionary",
0x3B06 => "IdentificationList",
0x1901 => "Mobs",
0x1902 => "EssenceData",
0x2603 => "OperationDefinitions",
0x2604 => "ParameterDefinitions",
0x2605 => "DataDefinitions",
0x2607 => "CodecDefinitions",
0x2608 => "ContainerDefinitions",
0x2609 => "InterpolationDefs",
0x4403 => "Slots",
0x4406 => "UserComments",
0x4803 => "Segment",
0x1001 => "Components",
0x1801 => "OperationGroup",
0x0B02 => "InputSegments",
0x0B03 => "Parameters",
0x4E02 => "PointList",
0x4701 => "EssenceDescription",
0x2F01 => "Locator",
0x2702 => "Data",
0x0204 => "ComponentComments",
0x1E09 => "ParametersDefined",
_ => "Property",
}
}
fn storage_name(pid: u16) -> String {
let suffix = format!("-{pid:x}");
let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect();
format!("{name}{suffix}")
}
/// Errors while reading the object tree.
fn bad(s: impl Into<String>) -> String {
s.into()
}
// ---------------------------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------------------------
struct W {
cfb: Writer,
paths: Vec<Vec<u16>>,
}
/// Serialise `root` (the Root object: MetaDictionary 0x0001, Header 0x0002) into a compound file.
pub(crate) fn write(root: &Obj, version: Version) -> Result<Vec<u8>, String> {
let mut w = W { cfb: Writer::new(version), paths: Vec::new() };
w.obj(Writer::ROOT, root)?;
let mut rp = vec![BYTE_ORDER_LE];
rp.extend_from_slice(&(w.paths.len() as u16).to_le_bytes());
let n: usize = w.paths.iter().map(|p| p.len() + 1).sum();
rp.extend_from_slice(&(n as u32).to_le_bytes());
for p in &w.paths {
for pid in p {
rp.extend_from_slice(&pid.to_le_bytes());
}
rp.extend_from_slice(&0u16.to_le_bytes());
}
w.cfb.stream(Writer::ROOT, "referenced properties", rp).map_err(|e| e.to_string())?;
Ok(w.cfb.finish())
}
impl W {
fn tag(&mut self, path: &[u16]) -> u16 {
match self.paths.iter().position(|p| p == path) {
Some(i) => i as u16,
None => {
self.paths.push(path.to_vec());
(self.paths.len() - 1) as u16
}
}
}
fn obj(&mut self, node: NodeId, o: &Obj) -> Result<(), String> {
self.cfb.set_clsid(node, o.class);
let mut entries: Vec<(u16, u16, Vec<u8>)> = Vec::new();
for (pid, v) in &o.props {
let pid = *pid;
let (sf, value) = match v {
Value::Data(d) => (SF_DATA, d.clone()),
Value::Strong(child) => {
let name = storage_name(pid);
let s = self.cfb.storage(node, &name, child.class).map_err(|e| e.to_string())?;
self.obj(s, child)?;
(SF_STRONG, utf16z(&name))
}
Value::StrongVec(items) | Value::StrongSet(items, _) => {
let name = storage_name(pid);
let mut index = Vec::new();
index.extend_from_slice(&(items.len() as u32).to_le_bytes());
index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key
index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key
let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None };
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0);
if let Some(k) = set_key {
index.extend_from_slice(&k.to_le_bytes());
index.push(key_size as u8);
}
for (i, item) in items.iter().enumerate() {
let s = self.cfb.storage(node, &format!("{name}{{{i:x}}}"), item.class).map_err(|e| e.to_string())?;
self.obj(s, item)?;
index.extend_from_slice(&(i as u32).to_le_bytes());
if let Some(k) = set_key {
index.extend_from_slice(&1u32.to_le_bytes());
let mut key = item.data(k).unwrap_or(&[]).to_vec();
key.resize(key_size, 0);
index.extend_from_slice(&key);
}
}
self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?;
(if set_key.is_some() { SF_STRONG_SET } else { SF_STRONG_VECTOR }, utf16z(&name))
}
Value::Weak(path, key_pid, key) => {
let mut d = self.tag(path).to_le_bytes().to_vec();
d.extend_from_slice(&key_pid.to_le_bytes());
d.push(key.len() as u8);
d.extend_from_slice(key);
(SF_WEAK, d)
}
Value::WeakVec(path, key_pid, keys) => {
let name = storage_name(pid);
let tag = self.tag(path);
let size = keys.first().map_or(0, Vec::len);
let mut index = (keys.len() as u32).to_le_bytes().to_vec();
index.extend_from_slice(&tag.to_le_bytes());
index.extend_from_slice(&key_pid.to_le_bytes());
index.push(size as u8);
for k in keys {
let mut k = k.clone();
k.resize(size, 0);
index.extend_from_slice(&k);
}
self.cfb.stream(node, &format!("{name} index"), index).map_err(|e| e.to_string())?;
(SF_WEAK_VECTOR, utf16z(&name))
}
Value::Stream(data) => {
let name = storage_name(pid);
self.cfb.stream(node, &name, data.clone()).map_err(|e| e.to_string())?;
let mut d = vec![BYTE_ORDER_LE];
d.extend_from_slice(&utf16z(&name));
(SF_DATA_STREAM, d)
}
};
if value.len() > u16::MAX as usize {
return Err(format!("property {pid:#06x} is {} bytes (the limit is 65535)", value.len()));
}
entries.push((pid, sf, value));
}
let mut props = vec![BYTE_ORDER_LE, FORMAT_VERSION];
props.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (pid, sf, v) in &entries {
props.extend_from_slice(&pid.to_le_bytes());
props.extend_from_slice(&sf.to_le_bytes());
props.extend_from_slice(&(v.len() as u16).to_le_bytes());
}
for (_, _, v) in &entries {
props.extend_from_slice(v);
}
self.cfb.stream(node, "properties", props).map_err(|e| e.to_string())?;
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Reading
// ---------------------------------------------------------------------------------------------
struct R<'a> {
cf: CompoundFile<'a>,
paths: Vec<Vec<u16>>,
objects: usize,
}
const MAX_DEPTH: usize = 200;
const MAX_OBJECTS: usize = 2_000_000;
/// Parse the object tree of an AAF compound file (the root object).
pub(crate) fn read(bytes: &[u8]) -> Result<Obj, String> {
let cf = CompoundFile::open(bytes).map_err(|e| e.to_string())?;
let mut paths = Vec::new();
if let Ok(rp) = cf.read_path("referenced properties")
&& rp.len() >= 7
{
let count = u16::from_le_bytes([rp[1], rp[2]]) as usize;
let mut cur = Vec::new();
for c in rp[7..].chunks_exact(2) {
let pid = u16::from_le_bytes([c[0], c[1]]);
if pid == 0 {
paths.push(std::mem::take(&mut cur));
if paths.len() == count {
break;
}
} else {
cur.push(pid);
}
}
}
let mut r = R { cf, paths, objects: 0 };
r.obj(0, 0)
}
fn rd_u16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn rd_u32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
impl R<'_> {
fn child(&self, parent: usize, name: &str) -> Result<usize, String> {
self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\"")))
}
fn stream(&self, parent: usize, name: &str) -> Result<Vec<u8>, String> {
let id = self.child(parent, name)?;
self.cf.read(id).map_err(|e| e.to_string())
}
fn obj(&mut self, entry: usize, depth: usize) -> Result<Obj, String> {
if depth > MAX_DEPTH {
return Err(bad("objects nested too deeply"));
}
self.objects += 1;
if self.objects > MAX_OBJECTS {
return Err(bad("too many objects"));
}
let e = self.cf.entry(entry).ok_or_else(|| bad("bad directory entry"))?;
if !matches!(e.kind, EntryKind::Root | EntryKind::Storage) {
return Err(bad(format!("\"{}\" is not a storage", e.name)));
}
let mut o = Obj::new(e.clsid);
let props = self.stream(entry, "properties")?;
if props.len() < 4 {
return Err(bad("short properties stream"));
}
if props[0] != BYTE_ORDER_LE {
return Err(bad("big-endian AAF files are not supported"));
}
let n = rd_u16(&props, 2).unwrap_or(0) as usize;
let mut at = 4 + n * 6;
if at > props.len() {
return Err(bad("truncated property index"));
}
for i in 0..n {
let (Some(pid), Some(sf), Some(len)) = (rd_u16(&props, 4 + i * 6), rd_u16(&props, 6 + i * 6), rd_u16(&props, 8 + i * 6)) else {
return Err(bad("truncated property index"));
};
let v = props.get(at..at + len as usize).ok_or_else(|| bad("truncated property value"))?;
at += len as usize;
let value = match sf {
SF_STRONG => {
let c = self.child(entry, &utf16_of(v))?;
Value::Strong(Box::new(self.obj(c, depth + 1)?))
}
SF_STRONG_VECTOR | SF_STRONG_SET => {
let name = utf16_of(v);
let idx = self.stream(entry, &format!("{name} index"))?;
let count = rd_u32(&idx, 0).ok_or_else(|| bad("short collection index"))? as usize;
let mut items = Vec::new();
if sf == SF_STRONG_VECTOR {
for k in 0..count {
let key = rd_u32(&idx, 12 + k * 4).ok_or_else(|| bad("short vector index"))?;
let c = self.child(entry, &format!("{name}{{{key:x}}}"))?;
items.push(self.obj(c, depth + 1)?);
}
Value::StrongVec(items)
} else {
let key_pid = rd_u16(&idx, 12).ok_or_else(|| bad("short set index"))?;
let key_size = *idx.get(14).ok_or_else(|| bad("short set index"))? as usize;
let stride = 8 + key_size;
for k in 0..count {
let key = rd_u32(&idx, 15 + k * stride).ok_or_else(|| bad("short set index"))?;
let c = self.child(entry, &format!("{name}{{{key:x}}}"))?;
items.push(self.obj(c, depth + 1)?);
}
Value::StrongSet(items, key_pid)
}
}
SF_WEAK => {
let (Some(tag), Some(key_pid), Some(&size)) = (rd_u16(v, 0), rd_u16(v, 2), v.get(4)) else {
return Err(bad("short weak reference"));
};
let key = v.get(5..5 + size as usize).ok_or_else(|| bad("short weak reference"))?.to_vec();
Value::Weak(self.paths.get(tag as usize).cloned().unwrap_or_default(), key_pid, key)
}
SF_WEAK_VECTOR | SF_WEAK_SET => {
let name = utf16_of(v);
let idx = self.stream(entry, &format!("{name} index"))?;
let (Some(count), Some(tag), Some(key_pid), Some(&size)) = (rd_u32(&idx, 0), rd_u16(&idx, 4), rd_u16(&idx, 6), idx.get(8)) else {
return Err(bad("short weak collection index"));
};
let size = size as usize;
let mut keys = Vec::new();
for k in 0..count as usize {
keys.push(idx.get(9 + k * size..9 + (k + 1) * size).ok_or_else(|| bad("short weak collection index"))?.to_vec());
}
Value::WeakVec(self.paths.get(tag as usize).cloned().unwrap_or_default(), key_pid, keys)
}
SF_DATA_STREAM => {
let name = utf16_of(v.get(1..).unwrap_or(&[]));
Value::Stream(self.stream(entry, &name)?)
}
_ => Value::Data(v.to_vec()),
};
o.props.push((pid, value));
}
Ok(o)
}
}

View File

@ -0,0 +1,266 @@
//! Structural checks of written AAF files against the required properties and rules of the AAF
//! Object Specification v1.1 and the Edit Protocol, read back through our compound file reader.
use std::collections::HashSet;
use filmcraft_project::{Project, SequenceSettings, TrackKind, TransitionAlign, TransitionId, find_effect};
use filmcraft_time::{FrameRate, TimeRange};
use super::ids::{self, cls, ddef, def, path, pid};
use super::store::{self, Obj, Value};
use super::*;
fn project() -> (Project, ItemId) {
let mut p = Project::new("t");
let info = filmcraft_media::MediaInfo {
name: "a.mov".into(),
kind: filmcraft_media::MediaKind::Movie,
duration: FrameRate::FPS_24.tick_of(2400),
video: Some(filmcraft_media::VideoStreamInfo {
width: 1280,
height: 720,
frame_rate: FrameRate::FPS_24,
par: (1, 1),
codec: String::new(),
pixel_format: String::new(),
color: Default::default(),
has_alpha: false,
bitrate: None,
}),
audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(24) }),
container: String::new(),
start_timecode: Some(86_400),
file_size: None,
};
let a = p.add_item(
"a.mov",
filmcraft_project::Label::Iris,
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
media: filmcraft_project::MediaRef::File { path: "/m/a.mov".into() },
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let r = FrameRate::FPS_24;
let s = p.new_sequence("S", SequenceSettings { frame_rate: r, ..Default::default() }, 1, 1, None);
let mut ids = Vec::new();
for (kind, start, dur, src) in
[(TrackKind::Video, 0, 48, 10), (TrackKind::Video, 48, 48, 200), (TrackKind::Audio, 0, 48, 10), (TrackKind::Audio, 48, 48, 200)]
{
let ti = p.make_track_item(a, kind, r.tick_of(start), TimeRange::new(r.tick_of(src), r.tick_of(dur)), r).unwrap();
ids.push(ti.id);
let q = p.sequence_mut(s).unwrap();
q.tracks_mut(kind)[0].items.push(ti);
}
for (kind, i, eff) in [(TrackKind::Video, 0, "cross_dissolve"), (TrackKind::Audio, 2, "constant_power")] {
let id = TransitionId(p.alloc_id());
let q = p.sequence_mut(s).unwrap();
q.tracks_mut(kind)[0].transitions.push(filmcraft_project::Transition {
id,
effect: find_effect(eff).unwrap().instance(),
start: r.tick_of(42),
duration: r.tick_of(12),
from: Some(ids[i]),
to: Some(ids[i + 1]),
align: TransitionAlign::CenterAtCut,
reverse: false,
});
}
let q = p.sequence_mut(s).unwrap();
q.audio_tracks[0].items[1].gain_db = -3.0;
(p, s)
}
fn walk<'a>(o: &'a Obj, out: &mut Vec<&'a Obj>) {
out.push(o);
for (_, v) in &o.props {
match v {
Value::Strong(c) => walk(c, out),
Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| walk(c, out)),
_ => {}
}
}
}
fn keys(dict: &Obj, p: u16) -> HashSet<Vec<u8>> {
dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect()
}
#[test]
fn written_files_have_the_required_structure() {
let (p, s) = project();
for small in [false, true] {
let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
// compound file level
let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap();
assert_eq!(cf.version, if small { 3 } else { 4 });
assert_eq!(cf.root().clsid, ids::ROOT);
assert!(cf.read_path("referenced properties").is_ok());
let h = cf.find("Header-2").unwrap();
assert_eq!(cf.entries[h].clsid, cls::HEADER);
assert!(cf.read_path("Header-2/properties").unwrap().starts_with(&[0x4C, 0x20]));
// object level
let root = store::read(&bytes).unwrap();
assert!(root.strong(pid::ROOT_META_DICTIONARY).is_some());
let header = root.strong(pid::ROOT_HEADER).unwrap();
for req in [pid::BYTE_ORDER, pid::LAST_MODIFIED, pid::CONTENT, pid::DICTIONARY, pid::VERSION, pid::IDENTIFICATION_LIST, pid::OBJECT_MODEL_VERSION] {
assert!(header.get(req).is_some(), "Header property {req:#06x}");
}
assert_eq!(header.auid(pid::OPERATIONAL_PATTERN), Some(def::OP_EDIT_PROTOCOL));
let ident = &header.objs(pid::IDENTIFICATION_LIST)[0];
for req in [pid::COMPANY_NAME, pid::PRODUCT_NAME, pid::PRODUCT_VERSION_STRING, pid::PRODUCT_ID, pid::DATE, pid::GENERATION_AUID] {
assert!(ident.get(req).is_some(), "Identification property {req:#06x}");
}
let dict = header.strong(pid::DICTIONARY).unwrap();
let ddefs = keys(dict, pid::DATA_DEFINITIONS);
let opdefs = keys(dict, pid::OPERATION_DEFINITIONS);
let idefs = keys(dict, pid::INTERPOLATION_DEFINITIONS);
let cdefs = keys(dict, pid::CONTAINER_DEFINITIONS);
let pdefs = keys(dict, pid::PARAMETER_DEFINITIONS);
let content = header.strong(pid::CONTENT).unwrap();
let mobs = content.objs(pid::MOBS);
let mob_ids: Vec<[u8; 32]> = mobs.iter().map(|m| m.mob_id(pid::MOB_ID).unwrap()).collect();
assert_eq!(mob_ids.iter().collect::<HashSet<_>>().len(), mob_ids.len(), "unique mob ids");
assert!(matches!(content.get(pid::MOBS), Some(Value::StrongSet(_, k)) if *k == pid::MOB_ID));
let comps: Vec<&Obj> = mobs.iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect();
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].auid(pid::USAGE_CODE), Some(def::USAGE_TOP_LEVEL));
assert_eq!(mobs.iter().filter(|m| m.class == cls::MASTER_MOB).count(), 1);
assert_eq!(mobs.iter().filter(|m| m.class == cls::SOURCE_MOB).count(), 3, "picture file, sound file, tape");
for m in mobs {
for req in [pid::MOB_ID, pid::MOB_NAME, pid::SLOTS, pid::MOB_LAST_MODIFIED, pid::MOB_CREATION_TIME] {
assert!(m.get(req).is_some(), "Mob property {req:#06x}");
}
if m.class == cls::SOURCE_MOB {
let d = m.strong(pid::ESSENCE_DESCRIPTION).expect("SourceMob::EssenceDescription");
if d.class != cls::TAPE_DESCRIPTOR {
for req in [pid::SAMPLE_RATE, pid::FILE_LENGTH, pid::CONTAINER_FORMAT] {
assert!(d.get(req).is_some(), "FileDescriptor property {req:#06x}");
}
assert!(cdefs.contains(d.weak_key(pid::CONTAINER_FORMAT).unwrap()));
let url = d.objs(pid::LOCATOR)[0].string(pid::URL_STRING).unwrap();
assert_eq!(url, "file:///m/a.mov");
}
if d.class == cls::PCM_DESCRIPTOR {
assert_eq!(d.u32(pid::CHANNELS), Some(2));
assert_eq!(d.u32(pid::QUANTIZATION_BITS), Some(24));
assert_eq!(d.u16(pid::BLOCK_ALIGN), Some(6));
}
}
for slot in m.objs(pid::SLOTS) {
assert!(slot.get(pid::SLOT_ID).is_some() && slot.get(pid::SEGMENT).is_some());
if slot.class == cls::TIMELINE_MOB_SLOT {
assert!(slot.rational(pid::EDIT_RATE).is_some_and(|r| r.0 > 0 && r.1 > 0));
assert!(slot.get(pid::ORIGIN).is_some());
}
}
let mut all = Vec::new();
walk(m, &mut all);
for o in all {
let is_component = [cls::FILLER, cls::SOURCE_CLIP, cls::SEQUENCE, cls::TRANSITION, cls::OPERATION_GROUP, cls::TIMECODE, cls::COMMENT_MARKER]
.contains(&o.class);
if is_component {
let dd = o.weak_key(pid::DATA_DEFINITION).expect("Component::DataDefinition");
assert!(ddefs.contains(dd), "data definition in the dictionary");
let event_seq = o.class == cls::SEQUENCE && dd == ddef::DESCRIPTIVE_METADATA;
assert!(event_seq || o.get(pid::LENGTH).is_some(), "Component::Length");
}
if o.class == cls::SOURCE_CLIP {
let id = o.mob_id(pid::SOURCE_ID).unwrap();
let slot = o.u32(pid::SOURCE_MOB_SLOT_ID).unwrap();
assert!(o.get(pid::START_TIME).is_some());
if id != [0; 32] {
let t = mobs.iter().find(|m| m.mob_id(pid::MOB_ID) == Some(id)).expect("source clip target in the file");
assert!(t.objs(pid::SLOTS).iter().any(|s| s.u32(pid::SLOT_ID) == Some(slot)), "target slot exists");
}
}
if o.class == cls::OPERATION_GROUP {
assert!(opdefs.contains(o.weak_key(pid::OPERATION).unwrap()));
for prm in o.objs(pid::PARAMETERS) {
assert!(pdefs.contains(prm.auid(pid::PARAMETER_DEFINITION).unwrap().as_slice()));
if prm.class == cls::VARYING_VALUE {
assert!(idefs.contains(prm.weak_key(pid::INTERPOLATION).unwrap()));
assert!(!prm.objs(pid::POINT_LIST).is_empty());
}
}
}
if o.class == cls::SEQUENCE && o.get(pid::LENGTH).is_some() {
let cs = o.objs(pid::COMPONENTS);
let mut sum = 0i64;
for (i, c) in cs.iter().enumerate() {
let l = c.i64(pid::LENGTH).unwrap();
if c.class == cls::TRANSITION {
// Edit Protocol: a transition sits between two segments at least as long
assert!(i > 0 && i + 1 < cs.len(), "transition at a sequence end");
assert!(cs[i - 1].class != cls::TRANSITION && cs[i + 1].class != cls::TRANSITION);
assert!(cs[i - 1].i64(pid::LENGTH).unwrap() >= l && cs[i + 1].i64(pid::LENGTH).unwrap() >= l);
let cut = c.i64(pid::CUT_POINT).unwrap();
assert!((0..=l).contains(&cut));
assert!(c.strong(pid::OPERATION_GROUP).is_some());
sum -= l;
} else {
sum += l;
}
}
assert_eq!(sum, o.i64(pid::LENGTH).unwrap(), "sequence length = segments − transitions");
}
}
}
// the composition: timecode + V1 + A1 slots, the video dissolve and the gain
let comp = comps[0];
let slots = comp.objs(pid::SLOTS);
let tc = slots[0].strong(pid::SEGMENT).unwrap();
assert_eq!(tc.class, cls::TIMECODE);
assert_eq!(tc.u16(pid::TC_FPS), Some(24));
let v1 = slots[1].strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS);
assert_eq!(v1.iter().map(|c| c.class).collect::<Vec<_>>(), vec![cls::SOURCE_CLIP, cls::TRANSITION, cls::SOURCE_CLIP]);
assert_eq!(v1[0].i64(pid::LENGTH), Some(54), "outgoing clip extends to the transition end");
assert_eq!(v1[2].i64(pid::LENGTH), Some(54), "incoming clip starts at the transition start");
assert_eq!(v1[2].i64(pid::START_TIME), Some(194));
assert_eq!(v1[1].i64(pid::CUT_POINT), Some(6));
assert_eq!(v1[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::VIDEO_DISSOLVE[..]));
let a1 = slots[2].strong(pid::SEGMENT).unwrap().objs(pid::COMPONENTS);
assert_eq!(slots[2].rational(pid::EDIT_RATE), Some((48_000, 1)));
assert_eq!(a1[2].class, cls::OPERATION_GROUP);
assert_eq!(a1[2].weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_GAIN[..]));
let amp = a1[2].objs(pid::PARAMETERS)[0].data(pid::CONSTANT_VALUE).unwrap();
let (n, d) = store::rational_of(&amp[17..]).unwrap();
assert!((n as f64 / d as f64 - 10f64.powf(-3.0 / 20.0)).abs() < 1e-4);
assert_eq!(a1[1].strong(pid::OPERATION_GROUP).unwrap().weak_key(pid::OPERATION), Some(&def::MONO_AUDIO_DISSOLVE[..]));
// weak reference paths are registered
let rp = cf.read_path("referenced properties").unwrap();
let pids: Vec<u16> = rp[7..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
assert!(pids.windows(3).any(|w| w == path::DATA_DEFS));
}
}
#[test]
fn store_round_trips_every_stored_form() {
let leaf = |n: u8| Obj::new([n; 16]).data_prop(0x1B01, vec![n; 16]);
let root = Obj::new(ids::ROOT)
.data_prop(0x0003, vec![1, 2, 3])
.with(0x0004, Value::Strong(Box::new(leaf(1).with(0x0005, Value::StrongVec(vec![leaf(2), leaf(3)])))))
.with(
0x0002,
Value::Strong(Box::new(
Obj::new([9; 16]).with(0x3B04, Value::Strong(Box::new(Obj::new([8; 16]).with(0x2605, Value::StrongSet(vec![leaf(4), leaf(5)], 0x1B01))))),
)),
)
.with(0x0006, Value::Weak(path::DATA_DEFS.to_vec(), 0x1B01, vec![5; 16]))
.with(0x0007, Value::WeakVec(path::DATA_DEFS.to_vec(), 0x1B01, vec![vec![4; 16], vec![5; 16]]))
.with(0x0008, Value::Stream((0..10_000u32).map(|i| i as u8).collect()));
for v in [filmcraft_cfb::Version::V3, filmcraft_cfb::Version::V4] {
let bytes = store::write(&root, v).unwrap();
assert_eq!(store::read(&bytes).unwrap(), root);
}
// a data value longer than a property can hold is an error, not a panic
assert!(store::write(&Obj::new(ids::ROOT).data_prop(1, vec![0; 70_000]), filmcraft_cfb::Version::V4).is_err());
}

View File

@ -0,0 +1,619 @@
//! Composition model → AAF objects.
use std::collections::HashMap;
use filmcraft_time::Tick;
use super::ids::{META_DICTIONARY, ROOT, cls, ddef, def, path, pid};
use super::store::{self, Auid, Obj, Value, rational, utf16z};
use crate::comp::{CItem, CKind, CMarker, Document, Gain, Source, ticks_to_units};
use crate::{Error, Report, Result};
/// UMID-style mob id (SMPTE 330 basic UMID): label, length 0x13, instance 0, material number.
pub(crate) fn mob_id(seed: u64, n: u64) -> [u8; 32] {
let mut id = [0u8; 32];
id[..12].copy_from_slice(&[0x06, 0x0A, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x05, 0x01, 0x01, 0x0F, 0x20]);
id[12] = 0x13;
let a = mix(seed ^ n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let b = mix(a ^ 0xD6E8_FEB8_6659_FD93);
id[16..24].copy_from_slice(&a.to_le_bytes());
id[24..32].copy_from_slice(&b.to_le_bytes());
// a "random" material number per RFC 4122 v4 layout keeps it a valid UUID
id[22] = (id[22] & 0x0F) | 0x40;
id[24] = (id[24] & 0x3F) | 0x80;
id
}
fn mix(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
pub(crate) fn fnv(s: &[u8], h: u64) -> u64 {
s.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3))
}
fn timestamp() -> Vec<u8> {
// 1970-01-01 00:00:00.00: FilmCraft writes reproducible files
let mut v = 1970i16.to_le_bytes().to_vec();
v.extend_from_slice(&[1, 1, 0, 0, 0, 0]);
v
}
fn weak(path: [u16; 3], key: Auid) -> Value {
Value::Weak(path.to_vec(), pid::IDENTIFICATION, key.to_vec())
}
fn data_def(kind: CKind) -> Auid {
match kind {
CKind::Picture => ddef::PICTURE,
CKind::Sound => ddef::SOUND,
}
}
/// An indirect value: byte order, type id, value.
pub(crate) fn indirect(ty: Auid, v: &[u8]) -> Vec<u8> {
let mut d = vec![0x4C];
d.extend_from_slice(&ty);
d.extend_from_slice(v);
d
}
fn tagged(name: &str, value: &str) -> Obj {
Obj::new(cls::TAGGED_VALUE).data_prop(pid::TAG_NAME, utf16z(name)).data_prop(pid::TAG_VALUE, indirect(def::TYPE_STRING, &utf16z(value)))
}
fn gain_rational(g: f64) -> Vec<u8> {
rational((g.max(0.0) * 65536.0).round().min(i32::MAX as f64) as i64, 65536)
}
fn definition(class: Auid, id: Auid, name: &str) -> Obj {
Obj::new(class).data_prop(pid::IDENTIFICATION, id.to_vec()).data_prop(pid::NAME, utf16z(name)).data_prop(pid::DESCRIPTION, utf16z(""))
}
struct W<'a> {
report: &'a mut Report,
inexact: bool,
}
impl W<'_> {
/// Edit units at `rate` of tick `t`.
fn u(&mut self, t: Tick, rate: (i64, i64)) -> i64 {
let (n, exact) = ticks_to_units(t, rate.0, rate.1);
if !exact {
self.inexact = true;
}
n
}
}
fn source_rate(s: &Source) -> (i64, i64) {
match s.kind {
CKind::Picture => (s.frame_rate.num, s.frame_rate.den),
CKind::Sound => (s.sample_rate.max(1) as i64, 1),
}
}
/// Serialise a composition document.
pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mut Report) -> Result<Vec<u8>> {
let comp = doc.compositions.first().ok_or(Error::Empty)?;
let mut w = W { report, inexact: false };
let mut seed = fnv(comp.name.as_bytes(), 0xCBF2_9CE4_8422_2325);
for s in &doc.sources {
seed = fnv(s.path.as_deref().unwrap_or("").as_bytes(), fnv(s.key.as_bytes(), seed));
}
seed = fnv(&(comp.tracks.len() as u64).to_le_bytes(), seed);
let mut next = 0u64;
let mut new_id = || {
next += 1;
mob_id(seed, next)
};
let mut mobs: Vec<Obj> = Vec::new();
let mut essence: Vec<Obj> = Vec::new();
let mut used_ops: Vec<Auid> = Vec::new();
let mut uses_varying = false;
// ---- media: master mobs (one per source key), file mobs (one per file and kind), tape mobs
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
for (i, s) in doc.sources.iter().enumerate() {
match groups.iter_mut().find(|(k, _)| *k == s.key) {
Some((_, v)) => v.push(i),
None => groups.push((s.key.clone(), vec![i])),
}
}
// source index → (master mob id, master slot id)
let mut master_ref: HashMap<usize, ([u8; 32], u32)> = HashMap::new();
for (_, members) in &groups {
let master_id = new_id();
let first = &doc.sources[members[0]];
// tape mob: timecode + one slot per essence kind
let tc = members.iter().find_map(|&i| doc.sources[i].start_tc.map(|t| (t, doc.sources[i].tc_rate)));
let tape_id = new_id();
let mut tape_slots = Vec::new();
if let Some((start, rate)) = tc {
let tcr = (rate.num, rate.den);
let len = members.iter().map(|&i| doc.sources[i].length).max().unwrap_or(Tick::ZERO);
let l = w.u(len, tcr);
tape_slots.push(timeline_slot(
1,
"TC1",
0,
tcr,
Obj::new(cls::TIMECODE)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::TIMECODE))
.data_prop(pid::LENGTH, l.to_le_bytes().to_vec())
.data_prop(pid::TC_START, start.to_le_bytes().to_vec())
.data_prop(pid::TC_FPS, (rate.timecode_base().clamp(1, u16::MAX as i64) as u16).to_le_bytes().to_vec())
.data_prop(pid::TC_DROP, vec![0]),
));
}
// file mobs keyed by (file, kind)
let mut files: Vec<((Option<String>, usize, CKind), [u8; 32], Vec<Obj>, usize)> = Vec::new();
let mut master_slots = Vec::new();
for (k, &si) in members.iter().enumerate() {
let s = &doc.sources[si];
let rate = source_rate(s);
let embedded_key = if s.embedded.is_some() { si } else { usize::MAX };
let fkey = (s.path.clone(), embedded_key, s.kind);
let file_slot = s.channel.map_or(1, |c| c + 1);
let len_units = w.u(s.length, rate);
let fi = match files.iter().position(|f| f.0 == fkey) {
Some(i) => i,
None => {
files.push((fkey, new_id(), Vec::new(), si));
files.len() - 1
}
};
let file_id = files[fi].1;
// the file slot → tape mob
let tape_slot_id = 2 + k as u32;
let tc_units = 0i64;
let clip = source_clip(s.kind, len_units, if tc.is_some() { tape_id } else { [0; 32] }, if tc.is_some() { tape_slot_id } else { 0 }, tc_units);
if !files[fi].2.iter().any(|o| o.u32(pid::SLOT_ID) == Some(file_slot)) {
files[fi].2.push(timeline_slot(file_slot, &slot_name(s.kind, file_slot), file_slot, rate, clip));
}
if tc.is_some() {
tape_slots.push(timeline_slot(
tape_slot_id,
&slot_name(s.kind, k as u32 + 1),
k as u32 + 1,
rate,
source_clip(s.kind, len_units, [0; 32], 0, 0),
));
}
let master_slot = k as u32 + 1;
master_slots.push(timeline_slot(
master_slot,
&slot_name(s.kind, master_slot),
master_slot,
rate,
source_clip(s.kind, len_units, file_id, file_slot, 0),
));
master_ref.insert(si, (master_id, master_slot));
}
// markers on the master mob (at the picture rate, else the first source's)
let marker_rate = members.iter().map(|&i| &doc.sources[i]).find(|s| s.kind == CKind::Picture).map(source_rate).unwrap_or_else(|| source_rate(first));
let markers: Vec<&CMarker> = members.iter().flat_map(|&i| doc.sources[i].markers.iter()).collect();
if !markers.is_empty() {
let mut uniq: Vec<CMarker> = markers.into_iter().cloned().collect();
uniq.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
uniq.dedup();
master_slots.push(event_slot(&mut w, members.len() as u32 + 1, marker_rate, &uniq));
}
mobs.push(
Obj::new(cls::MASTER_MOB)
.data_prop(pid::MOB_ID, master_id.to_vec())
.data_prop(pid::MOB_NAME, utf16z(&first.name))
.data_prop(pid::MOB_LAST_MODIFIED, timestamp())
.data_prop(pid::MOB_CREATION_TIME, timestamp())
.with(pid::SLOTS, Value::StrongVec(master_slots)),
);
for (_, file_id, slots, si) in files {
let s = &doc.sources[si];
let desc = descriptor(&mut w, s);
mobs.push(
Obj::new(cls::SOURCE_MOB)
.data_prop(pid::MOB_ID, file_id.to_vec())
.data_prop(pid::MOB_NAME, utf16z(&s.name))
.data_prop(pid::MOB_LAST_MODIFIED, timestamp())
.data_prop(pid::MOB_CREATION_TIME, timestamp())
.with(pid::SLOTS, Value::StrongVec(slots))
.with(pid::ESSENCE_DESCRIPTION, Value::Strong(Box::new(desc))),
);
if let Some(data) = &s.embedded {
essence
.push(Obj::new(cls::ESSENCE_DATA).data_prop(pid::ESSENCE_MOB_ID, file_id.to_vec()).with(pid::ESSENCE_STREAM, Value::Stream(data.clone())));
}
}
if tc.is_some() {
let reel = first.path.as_deref().map(crate::common::file_stem).unwrap_or(&first.name).to_string();
mobs.push(
Obj::new(cls::SOURCE_MOB)
.data_prop(pid::MOB_ID, tape_id.to_vec())
.data_prop(pid::MOB_NAME, utf16z(&reel))
.data_prop(pid::MOB_LAST_MODIFIED, timestamp())
.data_prop(pid::MOB_CREATION_TIME, timestamp())
.with(pid::SLOTS, Value::StrongVec(tape_slots))
.with(pid::ESSENCE_DESCRIPTION, Value::Strong(Box::new(Obj::new(cls::TAPE_DESCRIPTOR)))),
);
}
}
// ---- the composition
let crate_rate = (comp.rate.num, comp.rate.den);
let mut slots = Vec::new();
let total = comp
.tracks
.iter()
.map(|t| {
let mut c = Tick::ZERO;
for i in &t.items {
match i {
CItem::Transition(x) => c -= x.len,
other => c += other.len(),
}
}
c
})
.max()
.unwrap_or(Tick::ZERO);
let total_units = w.u(total, crate_rate);
slots.push(timeline_slot(
1,
"TC1",
0,
crate_rate,
Obj::new(cls::TIMECODE)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::TIMECODE))
.data_prop(pid::LENGTH, total_units.to_le_bytes().to_vec())
.data_prop(pid::TC_START, comp.start_tc.to_le_bytes().to_vec())
.data_prop(pid::TC_FPS, (comp.rate.timecode_base().clamp(1, u16::MAX as i64) as u16).to_le_bytes().to_vec())
.data_prop(pid::TC_DROP, vec![comp.drop as u8]),
));
let mut slot_id = 2u32;
for t in &comp.tracks {
let rate = match t.kind {
CKind::Picture => crate_rate,
CKind::Sound => (comp.sample_rate.max(1) as i64, 1),
};
let dd = data_def(t.kind);
let mut comps = Vec::new();
let mut cursor = Tick::ZERO;
for it in &t.items {
match it {
CItem::Filler(l) => {
let n = w.u(cursor + *l, rate) - w.u(cursor, rate);
cursor += *l;
comps.push(Obj::new(cls::FILLER).with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd)).data_prop(pid::LENGTH, n.to_le_bytes().to_vec()));
}
CItem::Clip(c) => {
let n = w.u(cursor + c.len, rate) - w.u(cursor, rate);
let s = &doc.sources[c.source];
let (mid, mslot) = master_ref.get(&c.source).copied().unwrap_or(([0; 32], 0));
let start = w.u(c.start, rate);
let mut sc = source_clip(t.kind, n, mid, mslot, start);
if !c.name.is_empty() && c.name != s.name {
sc.set(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(vec![tagged("Clip Name", &c.name)]));
}
let seg = match (&c.gain, t.kind) {
(Some(g), CKind::Sound) => {
if !used_ops.contains(&def::MONO_AUDIO_GAIN) {
used_ops.push(def::MONO_AUDIO_GAIN);
}
let param = match g {
Gain::Constant(a) => Obj::new(cls::CONSTANT_VALUE)
.data_prop(pid::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE.to_vec())
.data_prop(pid::CONSTANT_VALUE, indirect(def::TYPE_RATIONAL, &gain_rational(*a))),
Gain::Varying { linear, points } => {
uses_varying = true;
let pts = points
.iter()
.map(|(off, a)| {
let o = w.u(cursor + *off, rate) - w.u(cursor, rate);
Obj::new(cls::CONTROL_POINT)
.data_prop(pid::CP_TIME, rational(o, n.max(1)))
.data_prop(pid::CP_VALUE, indirect(def::TYPE_RATIONAL, &gain_rational(*a)))
.data_prop(pid::CP_EDIT_HINT, vec![0])
})
.collect();
Obj::new(cls::VARYING_VALUE)
.data_prop(pid::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE.to_vec())
.with(
pid::INTERPOLATION,
weak(path::INTERPOLATION_DEFS, if *linear { def::INTERP_LINEAR } else { def::INTERP_CONSTANT }),
)
.with(pid::POINT_LIST, Value::StrongVec(pts))
}
};
Obj::new(cls::OPERATION_GROUP)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
.data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
.with(pid::OPERATION, weak(path::OPERATION_DEFS, def::MONO_AUDIO_GAIN))
.with(pid::INPUT_SEGMENTS, Value::StrongVec(vec![sc]))
.with(pid::PARAMETERS, Value::StrongVec(vec![param]))
}
_ => sc,
};
comps.push(seg);
cursor += c.len;
}
CItem::Transition(x) => {
let ts = cursor - x.len;
let n = w.u(cursor, rate) - w.u(ts, rate);
let cut = w.u(ts + x.cut, rate) - w.u(ts, rate);
cursor = ts;
let op = transition_op(t.kind, &x.effect);
if !used_ops.contains(&op) {
used_ops.push(op);
}
let mut og = Obj::new(cls::OPERATION_GROUP)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
.data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
.with(pid::OPERATION, weak(path::OPERATION_DEFS, op))
.with(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(vec![tagged("FilmCraft Effect", &x.effect)]));
if op == def::VIDEO_DISSOLVE {
uses_varying = true;
let pts = [(0, 0), (1, 1)]
.iter()
.map(|&(t, v)| {
Obj::new(cls::CONTROL_POINT)
.data_prop(pid::CP_TIME, rational(t, 1))
.data_prop(pid::CP_VALUE, indirect(def::TYPE_RATIONAL, &rational(v, 1)))
.data_prop(pid::CP_EDIT_HINT, vec![0])
})
.collect();
og.set(
pid::PARAMETERS,
Value::StrongVec(vec![
Obj::new(cls::VARYING_VALUE)
.data_prop(pid::PARAMETER_DEFINITION, def::PARAM_LEVEL.to_vec())
.with(pid::INTERPOLATION, weak(path::INTERPOLATION_DEFS, def::INTERP_LINEAR))
.with(pid::POINT_LIST, Value::StrongVec(pts)),
]),
);
}
comps.push(
Obj::new(cls::TRANSITION)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
.data_prop(pid::LENGTH, n.to_le_bytes().to_vec())
.data_prop(pid::CUT_POINT, cut.to_le_bytes().to_vec())
.with(pid::OPERATION_GROUP, Value::Strong(Box::new(og))),
);
}
}
}
let len: i64 = comps
.iter()
.map(|c| {
let l = c.i64(pid::LENGTH).unwrap_or(0);
if c.class == cls::TRANSITION { -l } else { l }
})
.sum();
let seq = Obj::new(cls::SEQUENCE)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, dd))
.data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
.with(pid::COMPONENTS, Value::StrongVec(comps));
slots.push(timeline_slot(slot_id, &t.name, t.number, rate, seq));
slot_id += 1;
}
if !comp.markers.is_empty() {
slots.push(event_slot(&mut w, slot_id, crate_rate, &comp.markers));
}
let comp_id = new_id();
mobs.insert(
0,
Obj::new(cls::COMPOSITION_MOB)
.data_prop(pid::MOB_ID, comp_id.to_vec())
.data_prop(pid::MOB_NAME, utf16z(&comp.name))
.data_prop(pid::MOB_LAST_MODIFIED, timestamp())
.data_prop(pid::MOB_CREATION_TIME, timestamp())
.data_prop(pid::USAGE_CODE, def::USAGE_TOP_LEVEL.to_vec())
.with(pid::SLOTS, Value::StrongVec(slots))
.with(
pid::MOB_USER_COMMENTS,
Value::StrongVec(vec![
tagged("FilmCraft Frame Size", &format!("{}x{}", comp.width, comp.height)),
tagged("FilmCraft Audio Sample Rate", &comp.sample_rate.to_string()),
]),
),
);
if w.inexact {
w.report.info("some times are not on whole edit units and were rounded");
}
// ---- dictionary
let mut ddefs = Vec::new();
for (id, name) in [(ddef::PICTURE, "Picture"), (ddef::SOUND, "Sound"), (ddef::TIMECODE, "Timecode"), (ddef::DESCRIPTIVE_METADATA, "DescriptiveMetadata")] {
ddefs.push(definition(cls::DATA_DEFINITION, id, name));
}
let mut opdefs = Vec::new();
for op in &used_ops {
let (name, dd, inputs) = match *op {
x if x == def::VIDEO_DISSOLVE => ("Video Dissolve", ddef::PICTURE, 2),
x if x == def::VIDEO_FADE_TO_BLACK => ("Video Fade To Black", ddef::PICTURE, 1),
x if x == def::SMPTE_VIDEO_WIPE => ("SMPTE Video Wipe", ddef::PICTURE, 2),
x if x == def::MONO_AUDIO_DISSOLVE => ("Mono Audio Dissolve", ddef::SOUND, 2),
_ => ("Mono Audio Gain", ddef::SOUND, 1i32),
};
let params: Vec<Vec<u8>> = if *op == def::MONO_AUDIO_GAIN {
vec![def::PARAM_AMPLITUDE.to_vec()]
} else if *op == def::VIDEO_DISSOLVE {
vec![def::PARAM_LEVEL.to_vec()]
} else {
Vec::new()
};
let mut o = definition(cls::OPERATION_DEFINITION, *op, name)
.with(pid::OPDEF_DATA_DEFINITION, weak(path::DATA_DEFS, dd))
.data_prop(pid::OPDEF_IS_TIME_WARP, vec![0])
.data_prop(pid::OPDEF_NUMBER_INPUTS, inputs.to_le_bytes().to_vec());
if !params.is_empty() {
o.set(pid::OPDEF_PARAMETERS_DEFINED, Value::WeakVec(path::PARAMETER_DEFS.to_vec(), pid::IDENTIFICATION, params));
}
opdefs.push(o);
}
let pdefs = vec![
definition(cls::PARAMETER_DEFINITION, def::PARAM_LEVEL, "Level").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")),
definition(cls::PARAMETER_DEFINITION, def::PARAM_AMPLITUDE, "Amplitude").data_prop(pid::PARAMDEF_DISPLAY_UNITS, utf16z("")),
];
let idefs = if uses_varying || !used_ops.is_empty() {
vec![
definition(cls::INTERPOLATION_DEFINITION, def::INTERP_LINEAR, "Linear"),
definition(cls::INTERPOLATION_DEFINITION, def::INTERP_CONSTANT, "Constant"),
]
} else {
Vec::new()
};
let cdefs = vec![
definition(cls::CONTAINER_DEFINITION, def::CONTAINER_EXTERNAL, "External Container"),
definition(cls::CONTAINER_DEFINITION, def::CONTAINER_AAF, "AAF Container"),
];
let mut dict = Obj::new(cls::DICTIONARY)
.with(pid::DATA_DEFINITIONS, Value::StrongSet(ddefs, pid::IDENTIFICATION))
.with(pid::PARAMETER_DEFINITIONS, Value::StrongSet(pdefs, pid::IDENTIFICATION))
.with(pid::CONTAINER_DEFINITIONS, Value::StrongSet(cdefs, pid::IDENTIFICATION));
if !opdefs.is_empty() {
dict.set(pid::OPERATION_DEFINITIONS, Value::StrongSet(opdefs, pid::IDENTIFICATION));
}
if !idefs.is_empty() {
dict.set(pid::INTERPOLATION_DEFINITIONS, Value::StrongSet(idefs, pid::IDENTIFICATION));
}
let mut content = Obj::new(cls::CONTENT_STORAGE).with(pid::MOBS, Value::StrongSet(mobs, pid::MOB_ID));
if !essence.is_empty() {
content.set(pid::ESSENCE_DATA, Value::StrongSet(essence, pid::ESSENCE_MOB_ID));
}
let ident = Obj::new(cls::IDENTIFICATION)
.data_prop(pid::COMPANY_NAME, utf16z("FilmCraft"))
.data_prop(pid::PRODUCT_NAME, utf16z("FilmCraft"))
.data_prop(pid::PRODUCT_VERSION_STRING, utf16z(env!("CARGO_PKG_VERSION")))
.data_prop(pid::PRODUCT_ID, super::ids::guid(0x46494C4D, 0x4352, 0x4146, *b"T-AAF-EP").to_vec())
.data_prop(pid::DATE, timestamp())
.data_prop(pid::PLATFORM, utf16z("FilmCraft"))
.data_prop(pid::GENERATION_AUID, mob_id(seed, 0)[16..].to_vec());
let header = Obj::new(cls::HEADER)
.data_prop(pid::BYTE_ORDER, 0x4949i16.to_le_bytes().to_vec())
.data_prop(pid::LAST_MODIFIED, timestamp())
.data_prop(pid::VERSION, vec![1, 1])
.data_prop(pid::OBJECT_MODEL_VERSION, 1u32.to_le_bytes().to_vec())
.data_prop(pid::OPERATIONAL_PATTERN, def::OP_EDIT_PROTOCOL.to_vec())
.with(pid::CONTENT, Value::Strong(Box::new(content)))
.with(pid::DICTIONARY, Value::Strong(Box::new(dict)))
.with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident]));
let root = Obj::new(ROOT)
.with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY))))
.with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
store::write(&root, version).map_err(Error::Other)
}
fn slot_name(kind: CKind, n: u32) -> String {
match kind {
CKind::Picture => format!("V{n}"),
CKind::Sound => format!("A{n}"),
}
}
fn timeline_slot(id: u32, name: &str, number: u32, rate: (i64, i64), segment: Obj) -> Obj {
let mut o = Obj::new(cls::TIMELINE_MOB_SLOT)
.data_prop(pid::SLOT_ID, id.to_le_bytes().to_vec())
.data_prop(pid::SLOT_NAME, utf16z(name))
.data_prop(pid::EDIT_RATE, rational(rate.0, rate.1))
.data_prop(pid::ORIGIN, 0i64.to_le_bytes().to_vec())
.with(pid::SEGMENT, Value::Strong(Box::new(segment)));
if number > 0 {
o.set(pid::PHYSICAL_TRACK_NUMBER, Value::Data(number.to_le_bytes().to_vec()));
}
o
}
fn source_clip(kind: CKind, len: i64, source: [u8; 32], slot: u32, start: i64) -> Obj {
Obj::new(cls::SOURCE_CLIP)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, data_def(kind)))
.data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
.data_prop(pid::SOURCE_ID, source.to_vec())
.data_prop(pid::SOURCE_MOB_SLOT_ID, slot.to_le_bytes().to_vec())
.data_prop(pid::START_TIME, start.to_le_bytes().to_vec())
}
fn event_slot(w: &mut W, id: u32, rate: (i64, i64), markers: &[CMarker]) -> Obj {
let comps = markers
.iter()
.map(|m| {
let pos = w.u(m.start, rate);
let len = w.u(m.start + m.duration, rate) - pos;
let mut tags = vec![tagged("Name", &m.name), tagged("Comment", &m.comment)];
if let Some(c) = &m.color {
tags.push(tagged("Color", c));
}
Obj::new(cls::COMMENT_MARKER)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::DESCRIPTIVE_METADATA))
.data_prop(pid::LENGTH, len.to_le_bytes().to_vec())
.data_prop(pid::POSITION, pos.to_le_bytes().to_vec())
.data_prop(pid::COMMENT, utf16z(if m.comment.is_empty() { &m.name } else { &m.comment }))
.with(pid::COMPONENT_USER_COMMENTS, Value::StrongVec(tags))
})
.collect();
Obj::new(cls::EVENT_MOB_SLOT)
.data_prop(pid::SLOT_ID, id.to_le_bytes().to_vec())
.data_prop(pid::SLOT_NAME, utf16z("Markers"))
.data_prop(pid::EVENT_EDIT_RATE, rational(rate.0, rate.1))
.with(
pid::SEGMENT,
Value::Strong(Box::new(
Obj::new(cls::SEQUENCE)
.with(pid::DATA_DEFINITION, weak(path::DATA_DEFS, ddef::DESCRIPTIVE_METADATA))
.with(pid::COMPONENTS, Value::StrongVec(comps)),
)),
)
}
fn transition_op(kind: CKind, effect: &str) -> Auid {
match kind {
CKind::Sound => def::MONO_AUDIO_DISSOLVE,
CKind::Picture => match effect {
"dip_to_black" => def::VIDEO_FADE_TO_BLACK,
e if e.contains("wipe") => def::SMPTE_VIDEO_WIPE,
_ => def::VIDEO_DISSOLVE,
},
}
}
fn descriptor(w: &mut W, s: &Source) -> Obj {
let rate = source_rate(s);
let len = w.u(s.length, rate);
let container = if s.embedded.is_some() { def::CONTAINER_AAF } else { def::CONTAINER_EXTERNAL };
let mut d = match s.kind {
CKind::Picture => Obj::new(cls::CDCI_DESCRIPTOR)
.data_prop(pid::STORED_WIDTH, s.width.to_le_bytes().to_vec())
.data_prop(pid::STORED_HEIGHT, s.height.to_le_bytes().to_vec())
.data_prop(pid::FRAME_LAYOUT, vec![0])
.data_prop(pid::IMAGE_ASPECT_RATIO, rational(s.width.max(1) as i64, s.height.max(1) as i64))
.data_prop(pid::VIDEO_LINE_MAP, {
let mut v = 2u32.to_le_bytes().to_vec();
v.extend_from_slice(&4u32.to_le_bytes());
v.extend_from_slice(&0i32.to_le_bytes());
v.extend_from_slice(&0i32.to_le_bytes());
v
})
.data_prop(pid::COMPONENT_WIDTH, 8u32.to_le_bytes().to_vec())
.data_prop(pid::HORIZONTAL_SUBSAMPLING, 2u32.to_le_bytes().to_vec()),
CKind::Sound => {
let block = s.file_channels.max(1) * (s.bits as u32).div_ceil(8);
Obj::new(cls::PCM_DESCRIPTOR)
.data_prop(pid::AUDIO_SAMPLING_RATE, rational(s.sample_rate as i64, 1))
.data_prop(pid::LOCKED, vec![1])
.data_prop(pid::CHANNELS, s.file_channels.max(1).to_le_bytes().to_vec())
.data_prop(pid::QUANTIZATION_BITS, (s.bits as u32).to_le_bytes().to_vec())
.data_prop(pid::BLOCK_ALIGN, (block as u16).to_le_bytes().to_vec())
.data_prop(pid::AVERAGE_BPS, (block * s.sample_rate).to_le_bytes().to_vec())
}
};
d.set(pid::SAMPLE_RATE, Value::Data(rational(rate.0, rate.1)));
d.set(pid::FILE_LENGTH, Value::Data(len.to_le_bytes().to_vec()));
d.set(pid::CONTAINER_FORMAT, weak(path::CONTAINER_DEFS, container));
if let Some(p) = &s.path {
let url = if crate::common::is_absolute(p) { crate::common::path_to_file_url(p, false) } else { p.clone() };
d.set(pid::LOCATOR, Value::StrongVec(vec![Obj::new(cls::NETWORK_LOCATOR).data_prop(pid::URL_STRING, utf16z(&url))]));
}
d
}

View File

@ -0,0 +1,825 @@
//! A format-neutral mob-style composition model shared by AAF and OMF.
//!
//! Both formats describe a timeline as a composition of slots (tracks) whose segments are
//! sequences of fillers, source clips and transitions, where a transition *overlaps* the end of
//! the segment before it and the start of the segment after it (the sequence's length is the sum of
//! its segment lengths minus its transition lengths). Source clips reference media through master
//! mobs and file source mobs. [`from_project`] turns a FilmCraft sequence into that shape;
//! [`to_project`] builds FilmCraft sequences and media from it. All times are ticks; the format
//! serialisers convert to and from edit units.
use std::collections::HashMap;
use filmcraft_media::MediaKind;
use filmcraft_project::{
AudioChannels, ClipId, Interpolation, ItemId, ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, Sequence,
Track, TrackItem, TrackKind, Transition, TransitionAlign, TransitionId, find_effect,
};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::common::{Builder, MediaSpec, base_item, db_to_gain, file_name, file_stem, gain_to_db, param, set_param, settings_for, transition_effect};
use crate::essence::{EssenceData, EssenceKey, MediaOptions};
use crate::{ImportOptions, Imported, Report};
/// Picture or sound.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum CKind {
Picture,
Sound,
}
/// A whole document: compositions and the media they reference.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Document {
pub compositions: Vec<Composition>,
pub sources: Vec<Source>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Composition {
pub name: String,
pub rate: FrameRate,
pub sample_rate: u32,
pub width: u32,
pub height: u32,
pub start_tc: i64,
pub drop: bool,
pub tracks: Vec<CTrack>,
pub markers: Vec<CMarker>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CTrack {
pub kind: CKind,
pub name: String,
/// Physical track number (1-based, per kind).
pub number: u32,
/// Sound: channels of the track (1 mono, 2 stereo, 6 5.1).
pub channels: u32,
pub items: Vec<CItem>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum CItem {
Filler(Tick),
Clip(CClip),
Transition(CTransition),
}
impl CItem {
pub fn len(&self) -> Tick {
match self {
CItem::Filler(l) => *l,
CItem::Clip(c) => c.len,
CItem::Transition(t) => t.len,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CClip {
pub len: Tick,
/// Index into [`Document::sources`].
pub source: usize,
/// Source (file) time of the segment's first frame.
pub start: Tick,
pub gain: Option<Gain>,
pub name: String,
}
/// Clip gain (linear amplitude).
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Gain {
Constant(f64),
/// Points at offsets from the segment start; `linear` false = hold (step) interpolation.
Varying {
linear: bool,
points: Vec<(Tick, f64)>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CTransition {
pub len: Tick,
/// Where the cut lies, from the transition start.
pub cut: Tick,
/// FilmCraft effect id.
pub effect: String,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CMarker {
pub start: Tick,
pub duration: Tick,
pub name: String,
pub comment: String,
pub color: Option<String>,
}
/// One essence: a file (or embedded data) carrying the picture, or some channels of sound, of a
/// media item.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Source {
/// Groups the sources of one media item (one master mob).
pub key: String,
pub name: String,
pub kind: CKind,
pub path: Option<String>,
/// Sound: the (0-based) channel of the file this source exposes (broken out to mono), or
/// `None` for all of them.
pub channel: Option<u32>,
/// Sound: channels this source exposes (1 when `channel` is set, else `file_channels`).
pub channels: u32,
/// Sound: channels of the file / embedded data.
pub file_channels: u32,
pub width: u32,
pub height: u32,
/// Picture: the frame rate of the media.
pub frame_rate: FrameRate,
pub sample_rate: u32,
pub bits: u16,
pub length: Tick,
/// Media time of the essence's time 0 (trimmed / consolidated essence).
pub offset: Tick,
/// Start timecode in frames at `tc_rate`.
pub start_tc: Option<i64>,
pub tc_rate: FrameRate,
pub embedded: Option<Vec<u8>>,
pub markers: Vec<CMarker>,
}
impl Source {
pub fn is_still(&self) -> bool {
self.kind == CKind::Picture && self.length == Tick::ZERO
}
}
// ---------------------------------------------------------------------------------------------
// FilmCraft → composition
// ---------------------------------------------------------------------------------------------
/// How a transition sits on a clip.
#[derive(Clone, Copy, Debug)]
enum Side {
/// Shared with the adjacent clip: the transition's timeline range.
TwoSided(Tick, Tick),
/// Fade from / to nothing: the transition's length, anchored at the clip edge.
OneSided(Tick),
}
struct Exporter<'a> {
p: &'a Project,
media: &'a MediaOptions,
doc: Document,
memo: HashMap<(String, CKind, Option<u32>), usize>,
report: &'a mut Report,
seq_rate: FrameRate,
}
pub(crate) fn from_project(p: &Project, seq_id: ItemId, name: &str, media: &MediaOptions, report: &mut Report) -> crate::Result<Document> {
let seq = p.sequence(seq_id).ok_or(crate::Error::NoSequence(seq_id))?;
let rate = seq.settings.frame_rate;
let mut ex = Exporter { p, media, doc: Document::default(), memo: HashMap::new(), report, seq_rate: rate };
let mut comp = Composition {
name: name.to_string(),
rate,
sample_rate: seq.settings.sample_rate.max(1),
width: seq.settings.width,
height: seq.settings.height,
start_tc: seq.start_timecode,
drop: seq.settings.drop_frame,
tracks: Vec::new(),
markers: seq
.markers
.iter()
.map(|m| CMarker {
start: m.start,
duration: m.duration,
name: m.name.clone(),
comment: m.comment.clone(),
color: Some(m.color.name().to_string()),
})
.collect(),
};
if !media.audio_only {
if let Some(mix) = &media.mixdown_video {
let src = ex.doc.sources.len();
ex.doc.sources.push(Source {
key: "mixdown".into(),
name: format!("{name} (video mixdown)"),
kind: CKind::Picture,
path: Some(mix.path.clone()),
channel: None,
channels: 0,
file_channels: 0,
width: mix.width,
height: mix.height,
frame_rate: rate,
sample_rate: 0,
bits: 0,
length: mix.duration,
offset: Tick::ZERO,
start_tc: Some(seq.start_timecode + rate.frame_at(mix.start)),
tc_rate: rate,
embedded: None,
markers: Vec::new(),
});
let mut items = Vec::new();
if mix.start > Tick::ZERO {
items.push(CItem::Filler(mix.start));
}
items.push(CItem::Clip(CClip { len: mix.duration, source: src, start: Tick::ZERO, gain: None, name: format!("{name} (video mixdown)") }));
comp.tracks.push(CTrack { kind: CKind::Picture, name: "V1".into(), number: 1, channels: 0, items });
} else {
for (i, t) in seq.video_tracks.iter().enumerate() {
let items = ex.track(t, TrackKind::Video, None);
comp.tracks.push(CTrack { kind: CKind::Picture, name: format!("V{}", i + 1), number: i as u32 + 1, channels: 0, items });
}
}
}
let mut n = 0;
for t in &seq.audio_tracks {
let ch = match t.channels {
AudioChannels::Mono => 1,
AudioChannels::Surround51 => 6,
AudioChannels::Stereo | AudioChannels::Adaptive => 2,
};
if media.breakout_to_mono {
for c in 0..ch {
n += 1;
let items = ex.track(t, TrackKind::Audio, Some(c));
comp.tracks.push(CTrack { kind: CKind::Sound, name: format!("A{n}"), number: n, channels: 1, items });
}
} else {
n += 1;
let items = ex.track(t, TrackKind::Audio, None);
comp.tracks.push(CTrack { kind: CKind::Sound, name: format!("A{n}"), number: n, channels: ch, items });
}
}
ex.doc.compositions.push(comp);
Ok(ex.doc)
}
impl Exporter<'_> {
/// The source behind `c` on a track of `kind` (`sub`: broken-out channel slot), if exportable.
fn source_of(&mut self, c: &TrackItem, kind: TrackKind, sub: Option<u32>) -> Option<usize> {
let item = base_item(self.p, c.item);
let it = self.p.item(item)?;
let m = match &it.kind {
ItemKind::Media(m) => m,
ItemKind::Sequence(_) => {
self.report.warn("nested sequences are not exported (left as gaps); render and replace them first");
return None;
}
_ => {
self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)");
return None;
}
};
let path = match &m.media {
MediaRef::File { path } => path.clone(),
MediaRef::Generator(_) => {
self.report.warn("synthetic media (bars, colour mattes, …) is not exported (left as gaps)");
return None;
}
};
let ckind = if kind == TrackKind::Video { CKind::Picture } else { CKind::Sound };
// the source channel this slot plays when broken out to mono
let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s));
let clip_key = EssenceKey::Clip(c.id);
let media_key = EssenceKey::Media(item);
let ess = (ckind == CKind::Sound)
.then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel)))
.flatten()
.cloned();
let key = match &ess {
Some(e) if e.key == clip_key => format!("clip:{}", c.id.0),
_ => format!("item:{}", item.0),
};
let memo_key = (key.clone(), ckind, channel);
if let Some(&i) = self.memo.get(&memo_key) {
return Some(i);
}
let video = m.info.video.as_ref();
let audio = m.info.audio.as_ref();
let still = m.info.kind == MediaKind::Still;
match ckind {
CKind::Picture if video.is_none() && !still => return None,
CKind::Sound if audio.is_none() => return None,
_ => {}
}
let frame_rate = video.map(|v| v.frame_rate).filter(|r| r.num > 0).unwrap_or(self.seq_rate);
let mut markers: Vec<CMarker> = m.markers.iter().chain(c.markers.iter()).map(marker_out).collect();
markers.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
markers.dedup();
let mut s = Source {
key,
name: it.name.clone(),
kind: ckind,
path: Some(path.clone()),
channel: None,
channels: 0,
file_channels: 0,
width: video.map_or(0, |v| v.width),
height: video.map_or(0, |v| v.height),
frame_rate,
sample_rate: audio.map_or(48_000, |a| a.sample_rate.max(1)),
bits: audio.and_then(|a| a.bits_per_sample).unwrap_or(16).clamp(8, 32) as u16,
length: if still { Tick::ZERO } else { m.info.duration },
offset: Tick::ZERO,
start_tc: m.info.start_timecode,
tc_rate: frame_rate,
embedded: None,
markers,
};
if ckind == CKind::Sound {
s.file_channels = audio.map_or(2, |a| a.channels.max(1));
s.channel = channel;
if let Some(e) = ess {
s.sample_rate = e.sample_rate.max(1);
s.bits = e.bits;
s.file_channels = e.channels.max(1);
// per-channel essence is a mono file: its only channel
if e.channel.is_some() {
s.channel = None;
}
s.offset = e.start;
s.length = Tick::from_units(e.frames as i64, e.sample_rate.max(1) as i64);
match e.data {
EssenceData::Embedded(d) => {
s.path = None;
s.embedded = Some(d);
}
EssenceData::File { path } => s.path = Some(path),
}
if e.key == clip_key {
s.name = format!("{} (rendered)", c.name);
}
}
}
s.channels = if s.channel.is_some() { 1 } else { s.file_channels };
let i = self.doc.sources.len();
self.doc.sources.push(s);
self.memo.insert(memo_key, i);
Some(i)
}
fn gain_of(&mut self, c: &TrackItem, source: &Source) -> Option<Gain> {
if source.key.starts_with("clip:") {
return None; // rendered: effects baked in
}
let g = c.gain_db;
match param(c, "volume", "level") {
Some(p) if p.is_animated() => {
let speed = if c.speed.abs() > 1e-9 { c.speed.abs() } else { 1.0 };
let linear = !p.keyframes.iter().all(|k| k.interp == Interpolation::Hold);
if p.keyframes.iter().any(|k| !matches!(k.interp, Interpolation::Linear | Interpolation::Hold)) {
self.report.info("Bezier volume keyframes were exported as linear");
}
let points = p
.keyframes
.iter()
.map(|k| {
let off = Tick(((k.time - c.source_in).0 as f64 / speed).round() as i64);
(off, db_to_gain(k.value.as_f64().unwrap_or(0.0) + g))
})
.collect();
Some(Gain::Varying { linear, points })
}
Some(p) => {
let db = p.value.as_f64().unwrap_or(0.0) + g;
(db.abs() > 1e-9).then(|| Gain::Constant(db_to_gain(db)))
}
None => (g.abs() > 1e-9).then(|| Gain::Constant(db_to_gain(g))),
}
}
fn track(&mut self, t: &Track, kind: TrackKind, sub: Option<u32>) -> Vec<CItem> {
// exportable clips with their sources
let clips: Vec<(&TrackItem, usize)> = t.items.iter().filter_map(|c| self.source_of(c, kind, sub).map(|s| (c, s))).collect();
let idx_of: HashMap<ClipId, usize> = clips.iter().enumerate().map(|(i, (c, _))| (c.id, i)).collect();
let mut tin: Vec<Option<(Side, &Transition)>> = vec![None; clips.len()];
let mut tout: Vec<Option<(Side, &Transition)>> = vec![None; clips.len()];
for tr in &t.transitions {
let a = tr.from.and_then(|f| idx_of.get(&f).copied());
let b = tr.to.and_then(|f| idx_of.get(&f).copied());
match (a, b) {
(Some(a), Some(b)) if b == a + 1 && clips[a].0.end() == clips[b].0.start && tr.start <= clips[b].0.start && tr.end() >= clips[a].0.end() => {
tout[a] = Some((Side::TwoSided(tr.start, tr.end()), tr));
tin[b] = Some((Side::TwoSided(tr.start, tr.end()), tr));
}
(_, Some(b)) if tr.from.is_none() || a.is_none() => {
let c = clips[b].0;
let len = (tr.end().min(c.end()) - tr.start.max(c.start)).max(Tick::ZERO);
if tr.start != c.start {
self.report.info("a fade-in transition not starting at its clip was moved to the clip start");
}
if len > Tick::ZERO {
tin[b] = Some((Side::OneSided(len), tr));
}
}
(Some(a), _) => {
let c = clips[a].0;
let len = (tr.end().min(c.end()) - tr.start.max(c.start)).max(Tick::ZERO);
if tr.end() != c.end() {
self.report.info("a fade-out transition not ending at its clip was moved to the clip end");
}
if len > Tick::ZERO {
tout[a] = Some((Side::OneSided(len), tr));
}
}
_ => self.report.warn("a transition without an exported clip was dropped"),
}
}
let mut out = Vec::new();
let mut cursor = Tick::ZERO;
let mut tail = Tick::ZERO; // the timeline must reach at least here (after a fade-out)
for (i, &(c, src)) in clips.iter().enumerate() {
let (s, e) = (c.start, c.end());
let mut seg_start = s;
match tin[i] {
Some((Side::TwoSided(ts, _), _)) => seg_start = ts,
Some((Side::OneSided(len), tr)) => {
let fill_to = s + len;
if fill_to > cursor {
out.push(CItem::Filler(fill_to - cursor));
cursor = fill_to;
}
out.push(CItem::Transition(CTransition { len, cut: Tick::ZERO, effect: tr.effect.effect.clone() }));
cursor -= len;
}
None => {
if s > cursor {
out.push(CItem::Filler(s - cursor));
}
}
}
let seg_end = match tout[i] {
Some((Side::TwoSided(_, te), _)) => te,
_ => e,
};
if c.speed != 1.0 || c.reverse || c.frame_hold.is_some() {
self.report.warn("speed changes, reverse playback and frame holds are not exported (clips play at 100%)");
}
let source = self.doc.sources[src].clone();
// clips play at 100% here, so timeline and media advance together
let start = c.source_in + (seg_start - s) - source.offset;
if start < Tick::ZERO {
self.report.warn("a clip uses media before the start of its (trimmed) essence");
}
let mut gain = if kind == TrackKind::Audio { self.gain_of(c, &source) } else { None };
if let Some(Gain::Varying { points, .. }) = &mut gain {
for p in points.iter_mut() {
p.0 += s - seg_start;
}
}
out.push(CItem::Clip(CClip { len: seg_end - seg_start, source: src, start, gain, name: c.name.clone() }));
cursor = seg_end;
match tout[i] {
Some((Side::TwoSided(ts, _), tr)) => {
out.push(CItem::Transition(CTransition { len: seg_end - ts, cut: e - ts, effect: tr.effect.effect.clone() }));
cursor = ts;
tail = tail.max(seg_end);
}
Some((Side::OneSided(len), tr)) => {
out.push(CItem::Transition(CTransition { len, cut: len, effect: tr.effect.effect.clone() }));
cursor = e - len;
tail = tail.max(e);
}
None => tail = tail.max(e),
}
}
if tail > cursor {
out.push(CItem::Filler(tail - cursor));
}
out
}
}
fn marker_out(m: &Marker) -> CMarker {
CMarker { start: m.start, duration: m.duration, name: m.name.clone(), comment: m.comment.clone(), color: Some(m.color.name().to_string()) }
}
fn marker_in(b: &mut Builder, m: &CMarker) -> Marker {
Marker {
id: MarkerId(b.alloc()),
start: m.start,
duration: m.duration,
name: m.name.clone(),
comment: m.comment.clone(),
kind: MarkerKind::Comment,
color: m.color.as_deref().and_then(Label::from_name).unwrap_or(Label::Forest),
}
}
// ---------------------------------------------------------------------------------------------
// Composition → FilmCraft
// ---------------------------------------------------------------------------------------------
/// Media a document embeds, to be written next to it by the caller (the item's path).
#[derive(Clone, Debug, PartialEq)]
pub struct ExtractedMedia {
/// The imported media item (fragment-local id) whose path is `path`.
pub item: ItemId,
pub path: String,
/// A complete WAV file.
pub wav: Vec<u8>,
}
pub(crate) fn to_project(doc: Document, opts: &ImportOptions, report: &mut Report) -> crate::Result<(Imported, Vec<ExtractedMedia>)> {
if doc.compositions.is_empty() {
return Err(crate::Error::Empty);
}
let name = opts.name.clone().unwrap_or_else(|| doc.compositions[0].name.clone());
let mut b = Builder::new(&name);
let media_bin = b.bin("Media", None);
let mut extracted = Vec::new();
// one media item per (key, path)
let mut item_of: HashMap<usize, ItemId> = HashMap::new();
let mut groups: Vec<((String, Option<String>), Vec<usize>)> = Vec::new();
for (i, s) in doc.sources.iter().enumerate() {
let k = (s.key.clone(), s.path.clone().or_else(|| s.embedded.is_some().then(|| format!("embedded:{i}"))));
match groups.iter_mut().find(|(g, _)| *g == k) {
Some((_, v)) => v.push(i),
None => groups.push((k, vec![i])),
}
}
let doc_stem = opts.name.clone().unwrap_or_else(|| "Imported".into());
let mut used_paths = std::collections::HashSet::new();
for ((_, path), members) in &groups {
let srcs: Vec<&Source> = members.iter().map(|&i| &doc.sources[i]).collect();
let first = srcs[0];
let pic = srcs.iter().find(|s| s.kind == CKind::Picture);
let snd = srcs.iter().find(|s| s.kind == CKind::Sound);
let mut spec = MediaSpec {
duration: Some(srcs.iter().map(|s| s.length).max().unwrap_or(Tick::ZERO)),
video: pic.map(|p| (p.width.max(1), p.height.max(1), p.frame_rate)),
audio: snd.map(|s| {
(s.sample_rate, srcs.iter().filter(|x| x.kind == CKind::Sound).map(|x| x.file_channels.max(x.channel.map_or(0, |c| c + 1))).max().unwrap_or(1))
}),
start_tc: first.start_tc.or_else(|| srcs.iter().find_map(|s| s.start_tc)),
kind: None,
};
if pic.is_some_and(|p| p.is_still()) {
spec.kind = Some(MediaKind::Still);
}
let embedded = snd.and_then(|s| s.embedded.as_ref().map(|d| (*s, d)));
let (resolved, display) = match (path.as_deref(), embedded) {
(_, Some((s, _))) => {
let safe: String = first.name.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
let base = format!("{doc_stem} Media/{}{}", safe.trim(), s.channel.map(|c| format!(" ch{}", c + 1)).unwrap_or_default());
let mut rel = format!("{base}.wav");
let mut n = 1;
while !used_paths.insert(rel.to_ascii_lowercase()) {
n += 1;
rel = format!("{base} ({n}).wav");
}
(crate::common::resolve_path(&rel, opts.base_dir.as_deref()), first.name.clone())
}
(Some(p), None) => {
(crate::common::resolve_path(p, opts.base_dir.as_deref()), if first.name.is_empty() { file_name(p).to_string() } else { first.name.clone() })
}
(None, None) => (String::new(), first.name.clone()),
};
let display = if display.is_empty() { file_stem(&resolved).to_string() } else { display };
let key = format!("{}|{}", first.key, resolved);
let id = b.file_media(&key, &display, &resolved, &spec, Some(media_bin));
if resolved.is_empty()
&& let Some(m) = b.p.item_mut(id).and_then(|i| i.as_media_mut())
{
m.offline = true;
report.warn(format!("\"{display}\" has no media location; imported offline"));
}
let mut markers: Vec<CMarker> = srcs.iter().flat_map(|s| s.markers.iter().cloned()).collect();
markers.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
markers.dedup();
let mk: Vec<Marker> = markers.iter().map(|m| marker_in(&mut b, m)).collect();
if let Some(m) = b.p.item_mut(id).and_then(|i| i.as_media_mut()) {
m.markers = mk;
}
if let Some((s, data)) = embedded {
extracted.push(ExtractedMedia {
item: id,
path: resolved.clone(),
wav: crate::wav::wav_file(data, s.file_channels.max(1) as u16, s.sample_rate, s.bits),
});
}
for &i in members {
item_of.insert(i, id);
}
}
for comp in &doc.compositions {
let mut settings = settings_for(comp.rate, comp.width.max(16), comp.height.max(16), comp.drop);
settings.sample_rate = comp.sample_rate.max(1);
let seq_id = b.reserve_sequence(&comp.name, settings.clone(), None);
let mut seq = crate::common::empty_sequence(settings);
seq.start_timecode = comp.start_tc;
seq.markers = comp.markers.iter().map(|m| marker_in(&mut b, m)).collect();
for t in &comp.tracks {
let kind = if t.kind == CKind::Picture { TrackKind::Video } else { TrackKind::Audio };
let idx = seq.tracks(kind).len();
let mut track = b.track(kind, idx);
if kind == TrackKind::Audio {
track.channels = match t.channels {
1 => AudioChannels::Mono,
6 => AudioChannels::Surround51,
_ => AudioChannels::Stereo,
};
}
build_track(&mut b, &mut track, kind, t, &doc.sources, &item_of, report);
seq.tracks_mut(kind).push(track);
}
if seq.video_tracks.is_empty() {
let t = b.track(TrackKind::Video, 0);
seq.video_tracks.push(t);
}
if seq.audio_tracks.is_empty() {
let t = b.track(TrackKind::Audio, 0);
seq.audio_tracks.push(t);
}
link_clips(&mut b, &mut seq);
b.put_sequence(seq_id, seq);
b.top.push(seq_id);
}
Ok((b.finish(), extracted))
}
enum Placed {
Gap,
Clip(usize),
Tr { start: Tick, len: Tick, cut: Tick, effect: String },
}
fn build_track(b: &mut Builder, track: &mut Track, kind: TrackKind, t: &CTrack, sources: &[Source], item_of: &HashMap<usize, ItemId>, report: &mut Report) {
let mut pos = Tick::ZERO;
let mut placed: Vec<Placed> = Vec::new();
let mut clips: Vec<TrackItem> = Vec::new();
for it in &t.items {
if pos.0.abs() > 4 * MAX_TIME.0 {
report.warn("a track longer than FilmCraft supports was cut short");
break;
}
match it {
CItem::Filler(l) => {
pos += *l;
placed.push(Placed::Gap);
}
CItem::Transition(tr) => {
pos -= tr.len;
placed.push(Placed::Tr { start: pos, len: tr.len, cut: tr.cut, effect: tr.effect.clone() });
}
CItem::Clip(c) => {
let Some(&item) = item_of.get(&c.source) else {
pos += c.len;
placed.push(Placed::Gap);
continue;
};
let mut ti = b.clip(item, kind, if c.name.is_empty() { &sources[c.source].name } else { &c.name }, pos, c.len, c.start);
if kind == TrackKind::Audio {
let src = &sources[c.source];
if t.channels == 1 {
ti.source_channels = vec![src.channel.unwrap_or(0) as u16];
}
if let Some(g) = &c.gain {
apply_gain(&mut ti, g);
}
}
placed.push(Placed::Clip(clips.len()));
clips.push(ti);
pos += c.len;
}
}
}
// resolve transitions against their neighbours
let mut transitions = Vec::new();
for k in 0..placed.len() {
let Placed::Tr { start, len, cut, effect } = &placed[k] else { continue };
let prev = k.checked_sub(1).and_then(|i| match placed[i] {
Placed::Clip(c) => Some(c),
_ => None,
});
let next = placed.get(k + 1).and_then(|p| match p {
Placed::Clip(c) => Some(*c),
_ => None,
});
let (start, len, cut) = (*start, *len, (*cut).clamp(Tick::ZERO, *len));
let cut_at = start + cut;
match (prev, next) {
(Some(a), Some(bi)) => {
let a_end = clips[a].end();
if a_end > cut_at {
clips[a].duration = cut_at - clips[a].start;
}
let shift = cut_at - clips[bi].start;
if shift > Tick::ZERO {
// keyframes are in media time: unaffected
clips[bi].start += shift;
clips[bi].source_in += shift;
clips[bi].duration -= shift;
}
}
(None, None) => {
report.warn("a transition between two fillers was dropped");
continue;
}
_ => {}
}
let align = if cut == Tick::ZERO {
TransitionAlign::StartAtCut
} else if cut == len {
TransitionAlign::EndAtCut
} else {
TransitionAlign::CenterAtCut
};
let audio = kind == TrackKind::Audio;
let effect = match find_effect(effect) {
Some(d) if d.kind == if audio { filmcraft_project::EffectKind::AudioTransition } else { filmcraft_project::EffectKind::VideoTransition } => {
d.instance()
}
_ => transition_effect(effect, audio, report),
};
transitions.push(Transition {
id: TransitionId(b.alloc()),
effect,
start,
duration: len,
from: prev.map(|a| clips[a].id),
to: next.map(|c| clips[c].id),
align,
reverse: false,
});
}
clips.retain(|c| c.duration > Tick::ZERO);
track.items = clips;
track.transitions = transitions;
track.sort();
}
fn apply_gain(ti: &mut TrackItem, g: &Gain) {
match g {
Gain::Constant(a) => set_param(ti, "volume", "level", Param::new(ParamValue::Float(gain_to_db(*a)))),
Gain::Varying { linear, points } => {
let mut p = Param::new(ParamValue::Float(points.first().map_or(0.0, |x| gain_to_db(x.1))));
for (off, a) in points {
let off = (*off).clamp(-MAX_TIME, MAX_TIME);
let mut k = Keyframe::new(ti.source_in + off, ParamValue::Float(gain_to_db(*a)));
k.interp = if *linear { Interpolation::Linear } else { Interpolation::Hold };
p.keyframes.push(k);
}
p.keyframes.sort_by_key(|k| k.time);
set_param(ti, "volume", "level", p);
}
}
}
/// Link video and audio clips of the same media that start and end together.
fn link_clips(b: &mut Builder, seq: &mut Sequence) {
let mut groups: HashMap<(ItemId, Tick, Tick, Tick), Vec<(TrackKind, usize, usize)>> = HashMap::new();
for kind in [TrackKind::Video, TrackKind::Audio] {
for (ti, t) in seq.tracks(kind).iter().enumerate() {
for (ci, c) in t.items.iter().enumerate() {
groups.entry((c.item, c.start, c.duration, c.source_in)).or_default().push((kind, ti, ci));
}
}
}
let mut keys: Vec<_> = groups.keys().copied().collect();
keys.sort_by_key(|k| (k.1, k.0.0, k.2, k.3));
for k in keys {
let g = &groups[&k];
if g.len() < 2 {
continue;
}
let l = b.link_id();
for &(kind, ti, ci) in g {
seq.tracks_mut(kind)[ti].items[ci].link = Some(l);
}
}
}
/// The largest time read from a document (about 3.3 days).
pub(crate) const MAX_TIME: Tick = Tick(1 << 56);
/// Ticks of `n` edit units at rate `num/den` (rounded to the nearest tick).
pub(crate) fn units_to_ticks(n: i64, num: i64, den: i64) -> Tick {
if num <= 0 || den <= 0 {
return Tick::ZERO;
}
let t = n as i128 * den as i128 * TICKS_PER_SECOND as i128;
let q = t.div_euclid(num as i128);
let r = t.rem_euclid(num as i128);
// documents are untrusted: keep every time far from overflow (±3 days)
Tick((q + if r * 2 >= num as i128 { 1 } else { 0 }).clamp(-MAX_TIME.0 as i128, MAX_TIME.0 as i128) as i64)
}
/// Edit units at rate `num/den` nearest to `t`; the flag is false when `t` is not on a unit.
pub(crate) fn ticks_to_units(t: Tick, num: i64, den: i64) -> (i64, bool) {
if num <= 0 || den <= 0 {
return (0, false);
}
let x = t.0 as i128 * num as i128;
let d = den as i128 * TICKS_PER_SECOND as i128;
let q = x.div_euclid(d);
let r = x.rem_euclid(d);
let n = q + if r * 2 >= d { 1 } else { 0 };
(n.clamp(i64::MIN as i128, i64::MAX as i128) as i64, r == 0)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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