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:
parent
02a7039ac5
commit
0658967a5b
|
|
@ -1972,6 +1972,7 @@ dependencies = [
|
|||
name = "filmcraft-interchange"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(&[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());
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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", &(*a));
|
||||
v
|
||||
}
|
||||
Gain::Varying { linear, points } => {
|
||||
let mut pts = Vec::new();
|
||||
for (off, a) in points {
|
||||
let o = self.u(base + *off, rate) - self.u(base, rate);
|
||||
let p = self.obj(b"CTLP");
|
||||
self.b.set(p, "OMFI:CTLP:Time", "omfi:Rational", &rational(o, seg_len.max(1)));
|
||||
self.b.set(p, "OMFI:CTLP:Value", "omfi:DataValue", &(*a));
|
||||
self.b.set(p, "OMFI:CTLP:EditHint", "omfi:EditHintType", &[0]);
|
||||
pts.push(p);
|
||||
}
|
||||
let v = self.obj(b"VVAL");
|
||||
self.cpnt(v, "omfi:data:Rational", len);
|
||||
self.b.set(v, "OMFI:VVAL:Interpolation", "omfi:InterpKind", &(if *linear { 2u16 } else { 1u16 }).to_be_bytes());
|
||||
self.refs(v, "OMFI:VVAL:PointList", &pts);
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv(s: &[u8], h: u64) -> u64 {
|
||||
s.iter().fold(h, |h, &b| (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01B3))
|
||||
}
|
||||
|
||||
fn amp(a: f64) -> Vec<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
|
||||
}
|
||||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue