Merge branch 'main' into M3.13/M8.10
This commit is contained in:
commit
0948dcf682
|
|
@ -1655,7 +1655,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
|
|
@ -1676,7 +1676,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1686,7 +1686,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1694,14 +1694,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
|
|
@ -1717,7 +1717,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1728,14 +1728,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1746,9 +1746,13 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.1.1"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
|
|
@ -1764,7 +1768,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
|
|
@ -1796,7 +1800,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1804,7 +1808,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1814,7 +1818,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1826,7 +1830,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -1839,6 +1843,7 @@ dependencies = [
|
|||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
|
|
@ -1856,7 +1861,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-audio-dsp",
|
||||
|
|
@ -1868,6 +1873,7 @@ dependencies = [
|
|||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-render",
|
||||
|
|
@ -1884,7 +1890,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1896,7 +1902,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
|
|
@ -1906,7 +1912,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1914,7 +1920,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1931,7 +1937,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1948,7 +1954,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1958,7 +1964,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1968,7 +1974,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1978,8 +1984,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
|
|
@ -1994,7 +2001,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2005,7 +2012,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
@ -2013,7 +2020,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -2026,7 +2033,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
|
|
@ -2035,7 +2042,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -2043,28 +2050,28 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
|
|
@ -2078,7 +2085,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2088,7 +2095,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -2108,7 +2115,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
|
|
@ -2117,7 +2124,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -2135,14 +2142,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
|
|
@ -2153,7 +2160,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -2161,7 +2168,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -2174,6 +2181,7 @@ dependencies = [
|
|||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-gpu",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
|
|
@ -2192,7 +2200,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
|
@ -2201,7 +2209,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -7132,7 +7140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -19,6 +19,7 @@ filmcraft-bitstream = { path = "crates/bitstream" }
|
|||
filmcraft-isobmff = { path = "crates/isobmff" }
|
||||
filmcraft-matroska = { path = "crates/matroska" }
|
||||
filmcraft-mxf = { path = "crates/mxf" }
|
||||
filmcraft-cfb = { path = "crates/cfb" }
|
||||
filmcraft-mpegts = { path = "crates/mpegts" }
|
||||
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
|
||||
filmcraft-ac3 = { path = "crates/ac3" }
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
|
|||
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
|
||||
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, PNG sequences, animated GIF and WAV.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Background jobs** with progress and cancel, so you keep editing while it renders.
|
||||
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
|
||||
|
||||
|
|
@ -211,6 +211,8 @@ Move timelines between FilmCraft and every other editor:
|
|||
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
|
||||
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
|
||||
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
|
||||
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
|
||||
|
||||
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
|
||||
|
||||
**Last updated:** 2026-10-02 · **Overall parity:** ~86% (measured scorecard below) · **Code:** 34 crates, 1562 tests
|
||||
**Last updated:** 2026-10-02 · **Overall parity:** ~87% (measured scorecard below) · **Code:** 34 crates, 1605 tests
|
||||
|
||||
## Parity scorecard
|
||||
|
||||
|
|
@ -20,10 +20,10 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
|
|||
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
|
||||
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, image sequences, GIF, WAV; preset library + user presets, full Export-mode settings (loudness normalization, overlays, captions), export queue, Quick Export; no AAF/OMF, MXF export | ~80% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; 8K and AV1 not yet real-time on a loaded machine | ~62% |
|
||||
| **Weighted total** | | | **~86%** |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
|
||||
|
|
@ -77,6 +77,8 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
|
||||
## Log
|
||||
|
||||
- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
|
||||
|
||||
- **2026-10-04:** MPEG-2 (M9.15): own MPEG-2/MPEG-1 video decoder (4:2:2, interlaced, field pictures; IEEE 1180 IDCT), MPEG TS/PS demux (AVCHD .mts, .m2ts, .ts, .mpg/.vob/.mod), own AC-3 decoder (ATSC A/52), MP2, LATM AAC, Blu-ray/DVD LPCM, MPEG-2 in MXF/MOV/MP4/MKV. 1562 tests.
|
||||
|
||||
- **2026-10-03 (evening):** Project panel and Media Browser (M12.7): Freeform view (stacks, arrangements), Icon view hover scrub with In/Out, list columns from all metadata, ten view presets, multiple Project panels; Media Browser with Favorites/drives/recent, back/forward, filters, thumbnails, Edit Columns, ingest, drag to timeline; all as `project.*` / `mediaBrowser.*` commands. 1513 tests.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ SUBCOMMANDS
|
|||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
||||
export the active sequence and wait for it to finish: with an
|
||||
export preset (`export --list-presets`; built-in or the user's), or
|
||||
a format (h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
from the extension); --range entire|inOut|workArea, or a custom
|
||||
range in seconds; --settings is ExportSettings JSON merged over the
|
||||
preset; --queue adds to the export queue and runs it instead
|
||||
|
|
@ -136,7 +136,7 @@ fn format_for(path: &str) -> Option<&'static str> {
|
|||
Some(match ext.as_str() {
|
||||
"mp4" | "m4v" => "h264",
|
||||
"mov" => "prores",
|
||||
"mxf" => "dnxhr",
|
||||
"mxf" => "mxf-op1a",
|
||||
"png" => "png",
|
||||
"gif" => "gif",
|
||||
"wav" => "wav",
|
||||
|
|
@ -366,3 +366,13 @@ async fn main() {
|
|||
other => usage(format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
assert_eq!(super::format_for("a.mov"), Some("prores"));
|
||||
assert_eq!(super::format_for("noext"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "filmcraft-cfb"
|
||||
description = "Clean-room Compound File Binary (Microsoft structured storage, [MS-CFB]) reader and writer: the container of AAF files"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# filmcraft-cfb
|
||||
|
||||
A clean-room reader and writer for the Compound File Binary format (Microsoft "structured
|
||||
storage"), the low-level container of AAF files. Layer L0: no dependencies beyond `std`, no
|
||||
`unsafe`, builds for `wasm32-unknown-unknown`. No third-party implementation (the AAF SDK's
|
||||
structured storage code, libgsf, olefile, POI…) was consulted.
|
||||
|
||||
## Specification
|
||||
|
||||
| Document | Edition | Used for |
|
||||
|---|---|---|
|
||||
| [MS-CFB] Compound File Binary File Format (Microsoft Open Specifications) | revision 10.0 (the format is unchanged since 1.0) | header, sector chains, FAT / DIFAT / mini FAT, directory entries, red-black sibling trees, name comparison, version 3 and 4 files |
|
||||
|
||||
## Reading
|
||||
|
||||
```rust
|
||||
let bytes = std::fs::read("sequence.aaf")?;
|
||||
let cf = filmcraft_cfb::CompoundFile::open(&bytes)?;
|
||||
let header = cf.find("Header-2")?; // a storage
|
||||
println!("{:02x?}", cf.entries[header].clsid); // its class id
|
||||
let props = cf.read_path("Header-2/properties")?; // a stream
|
||||
for &child in &cf.entries[header].children { /* sorted siblings */ }
|
||||
```
|
||||
|
||||
- Accepts version 3 (512-byte sectors) and version 4 (4096-byte sectors) files; other sector
|
||||
shifts between 7 and 16 are tolerated.
|
||||
- Every chain (DIFAT, FAT, mini FAT, directory, streams) is bounds-checked and cycle-checked; a
|
||||
directory tree that references an entry twice is rejected. Damaged or truncated files return
|
||||
`Error::Invalid`, never panic or loop.
|
||||
- Version 3 stream sizes ignore the high 32 bits (§2.6.3).
|
||||
|
||||
## Writing
|
||||
|
||||
```rust
|
||||
let mut w = filmcraft_cfb::Writer::new(filmcraft_cfb::Version::V4);
|
||||
w.set_clsid(filmcraft_cfb::Writer::ROOT, root_class);
|
||||
let h = w.storage(filmcraft_cfb::Writer::ROOT, "Header-2", header_class)?;
|
||||
w.stream(h, "properties", bytes)?;
|
||||
let file: Vec<u8> = w.finish();
|
||||
```
|
||||
|
||||
Layout: stream sectors (streams ≥ 4096 bytes), mini stream, mini FAT, directory, FAT, DIFAT. Each
|
||||
chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB] name order
|
||||
(length first, then upper-cased code units) coloured as valid red-black trees. Names are checked
|
||||
(1–31 UTF-16 code units, none of `/ \ : !`, unique per storage, case-insensitively).
|
||||
|
||||
## Tests
|
||||
|
||||
`src/tests.rs`: round trips for both versions with empty, mini, boundary (63/64/65, 4095/4096/4097
|
||||
bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
|
||||
a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
|
||||
29 bytes and 1500 random mutations never panic and only return complete streams.
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
//! Clean-room Compound File Binary format reader and writer ("structured storage", the container
|
||||
//! of AAF files), written from the public Microsoft Open Specification **[MS-CFB]: Compound File
|
||||
//! Binary File Format** (protocol revision 10.0 and earlier editions; the on-disk format has not
|
||||
//! changed since 1.0).
|
||||
//!
|
||||
//! A compound file is a small FAT file system inside one file: fixed-size sectors (512 bytes in
|
||||
//! version 3, 4096 bytes in version 4) chained by a file allocation table, a directory of 128-byte
|
||||
//! entries arranged as one red-black tree of siblings per storage, and a "mini stream" with a mini
|
||||
//! FAT for streams smaller than 4096 bytes.
|
||||
//!
|
||||
//! - [`CompoundFile::open`] parses a file held in memory (no copies of large streams; every chain
|
||||
//! is bounds- and cycle-checked, so damaged files give errors, never panics or endless loops).
|
||||
//! - [`Writer`] builds a tree of storages and streams and lays it out in one pass
|
||||
//! ([`Writer::finish`]): stream sectors, mini stream, mini FAT, directory, FAT and DIFAT.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use read::{CompoundFile, Entry, EntryKind};
|
||||
pub use write::{NodeId, Version, Writer};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
/// The 8-byte header signature (§2.2).
|
||||
pub const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
/// Largest regular sector number (§2.1).
|
||||
pub const MAXREGSECT: u32 = 0xFFFF_FFFA;
|
||||
/// Sector used by the DIFAT.
|
||||
pub const DIFSECT: u32 = 0xFFFF_FFFC;
|
||||
/// Sector used by the FAT.
|
||||
pub const FATSECT: u32 = 0xFFFF_FFFD;
|
||||
/// End of a sector chain.
|
||||
pub const ENDOFCHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Unallocated sector.
|
||||
pub const FREESECT: u32 = 0xFFFF_FFFF;
|
||||
/// No sibling / child directory entry.
|
||||
pub const NOSTREAM: u32 = 0xFFFF_FFFF;
|
||||
/// Streams shorter than this live in the mini stream (§2.2 `Mini Stream Cutoff Size`).
|
||||
pub const MINI_STREAM_CUTOFF: u32 = 4096;
|
||||
/// Mini sector size (§2.2: mini sector shift 6).
|
||||
pub const MINI_SECTOR_SIZE: usize = 64;
|
||||
/// Longest directory entry name in UTF-16 code units, without the terminating NUL (§2.6.1).
|
||||
pub const MAX_NAME_LEN: usize = 31;
|
||||
|
||||
/// Errors from the reader and writer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not a compound file (bad signature or byte order mark).
|
||||
NotCfb,
|
||||
/// Structurally invalid or truncated.
|
||||
Invalid(String),
|
||||
/// No entry with that name / path.
|
||||
NotFound(String),
|
||||
/// A name the format cannot store (too long, illegal characters, duplicate).
|
||||
BadName(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::NotCfb => f.write_str("not a compound file"),
|
||||
Error::Invalid(s) => write!(f, "invalid compound file: {s}"),
|
||||
Error::NotFound(s) => write!(f, "no entry {s:?}"),
|
||||
Error::BadName(s) => write!(f, "invalid entry name {s:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Whether `head` starts with the compound file signature.
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
head.len() >= 8 && head[..8] == SIGNATURE
|
||||
}
|
||||
|
||||
fn upper(u: u16) -> u16 {
|
||||
if (b'a' as u16..=b'z' as u16).contains(&u) {
|
||||
return u - 32;
|
||||
}
|
||||
if u < 0x80 {
|
||||
return u;
|
||||
}
|
||||
match char::from_u32(u as u32) {
|
||||
Some(c) => {
|
||||
let mut up = c.to_uppercase();
|
||||
match (up.next(), up.next()) {
|
||||
(Some(x), None) if (x as u32) <= 0xFFFF => x as u32 as u16,
|
||||
_ => u,
|
||||
}
|
||||
}
|
||||
None => u,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sibling order of directory entries (§2.6.4): shorter names first, then a code-unit
|
||||
/// comparison of the upper-cased names.
|
||||
pub fn compare_names(a: &str, b: &str) -> Ordering {
|
||||
let ua: Vec<u16> = a.encode_utf16().collect();
|
||||
let ub: Vec<u16> = b.encode_utf16().collect();
|
||||
ua.len().cmp(&ub.len()).then_with(|| ua.iter().map(|&u| upper(u)).cmp(ub.iter().map(|&u| upper(u))))
|
||||
}
|
||||
|
||||
/// Check a directory entry name (§2.6.1): 1–31 UTF-16 code units, none of `/ \ : !`.
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
let n = name.encode_utf16().count();
|
||||
if n == 0 || n > MAX_NAME_LEN || name.contains(['/', '\\', ':', '!']) {
|
||||
return Err(Error::BadName(name.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
//! Reading compound files ([MS-CFB] §2.2–2.6).
|
||||
|
||||
use crate::{ENDOFCHAIN, Error, FREESECT, MAXREGSECT, MINI_SECTOR_SIZE, NOSTREAM, Result, SIGNATURE, compare_names};
|
||||
|
||||
/// Directory entry object type (§2.6.1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
Root,
|
||||
Storage,
|
||||
Stream,
|
||||
/// Unallocated (type 0) or an unknown type.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One directory entry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub kind: EntryKind,
|
||||
/// Class id of a storage (AAF: the class of the object stored in it).
|
||||
pub clsid: [u8; 16],
|
||||
pub state_bits: u32,
|
||||
/// FILETIME stamps (0 when not set).
|
||||
pub created: u64,
|
||||
pub modified: u64,
|
||||
/// First sector (regular or mini) of a stream; the mini stream's for the root.
|
||||
pub start: u32,
|
||||
pub size: u64,
|
||||
/// Child entries of a storage, in sibling-tree order (sorted by [`compare_names`]).
|
||||
pub children: Vec<usize>,
|
||||
left: u32,
|
||||
right: u32,
|
||||
child: u32,
|
||||
}
|
||||
|
||||
/// A parsed compound file over borrowed bytes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompoundFile<'a> {
|
||||
data: &'a [u8],
|
||||
/// Major version: 3 (512-byte sectors) or 4 (4096-byte sectors).
|
||||
pub version: u16,
|
||||
/// Header CLSID (normally zero).
|
||||
pub header_clsid: [u8; 16],
|
||||
sector_size: usize,
|
||||
mini_cutoff: u64,
|
||||
fat: Vec<u32>,
|
||||
minifat: Vec<u32>,
|
||||
mini_stream: Vec<u8>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> u16 {
|
||||
u16::from_le_bytes([b[at], b[at + 1]])
|
||||
}
|
||||
fn u32_at(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
|
||||
}
|
||||
fn u64_at(b: &[u8], at: usize) -> u64 {
|
||||
u64::from_le_bytes(b[at..at + 8].try_into().unwrap_or([0; 8]))
|
||||
}
|
||||
|
||||
fn invalid(s: impl Into<String>) -> Error {
|
||||
Error::Invalid(s.into())
|
||||
}
|
||||
|
||||
impl<'a> CompoundFile<'a> {
|
||||
/// Parse the header, FAT, mini FAT, directory and mini stream of `data`.
|
||||
pub fn open(data: &'a [u8]) -> Result<CompoundFile<'a>> {
|
||||
if data.len() < 512 || data[..8] != SIGNATURE || u16_at(data, 28) != 0xFFFE {
|
||||
return Err(Error::NotCfb);
|
||||
}
|
||||
let version = u16_at(data, 26);
|
||||
let shift = u16_at(data, 30);
|
||||
if !(7..=16).contains(&shift) {
|
||||
return Err(invalid(format!("sector shift {shift}")));
|
||||
}
|
||||
let mini_shift = u16_at(data, 32);
|
||||
if 1usize.checked_shl(mini_shift as u32) != Some(MINI_SECTOR_SIZE) {
|
||||
return Err(invalid(format!("mini sector shift {mini_shift}")));
|
||||
}
|
||||
let sector_size = 1usize << shift;
|
||||
let n_fat = u32_at(data, 44) as usize;
|
||||
let first_dir = u32_at(data, 48);
|
||||
let mini_cutoff = u32_at(data, 56) as u64;
|
||||
let first_minifat = u32_at(data, 60);
|
||||
let first_difat = u32_at(data, 68);
|
||||
let n_difat = u32_at(data, 72) as usize;
|
||||
let mut header_clsid = [0u8; 16];
|
||||
header_clsid.copy_from_slice(&data[8..24]);
|
||||
let max_sectors = data.len() / sector_size + 1;
|
||||
if n_fat > max_sectors {
|
||||
return Err(invalid(format!("{n_fat} FAT sectors in a {}-byte file", data.len())));
|
||||
}
|
||||
let mut cf = CompoundFile {
|
||||
data,
|
||||
version,
|
||||
header_clsid,
|
||||
sector_size,
|
||||
mini_cutoff,
|
||||
fat: Vec::new(),
|
||||
minifat: Vec::new(),
|
||||
mini_stream: Vec::new(),
|
||||
entries: Vec::new(),
|
||||
};
|
||||
// FAT sector numbers: 109 in the header, then the DIFAT chain.
|
||||
let mut fat_sectors: Vec<u32> = (0..109).map(|i| u32_at(data, 76 + i * 4)).filter(|&s| s <= MAXREGSECT).collect();
|
||||
let mut d = first_difat;
|
||||
let mut seen = 0;
|
||||
while d <= MAXREGSECT && fat_sectors.len() < n_fat {
|
||||
seen += 1;
|
||||
if seen > n_difat.max(1) + max_sectors {
|
||||
return Err(invalid("DIFAT chain loops"));
|
||||
}
|
||||
let s = cf.sector(d)?;
|
||||
let per = sector_size / 4 - 1;
|
||||
for i in 0..per {
|
||||
let v = u32_at(s, i * 4);
|
||||
if v <= MAXREGSECT {
|
||||
fat_sectors.push(v);
|
||||
}
|
||||
}
|
||||
d = u32_at(s, per * 4);
|
||||
}
|
||||
fat_sectors.truncate(n_fat);
|
||||
let mut fat = Vec::with_capacity(fat_sectors.len() * sector_size / 4);
|
||||
for s in fat_sectors {
|
||||
let b = cf.sector(s)?;
|
||||
fat.extend((0..sector_size / 4).map(|i| u32_at(b, i * 4)));
|
||||
}
|
||||
cf.fat = fat;
|
||||
// Directory.
|
||||
let dir = cf.read_chain(first_dir, None)?;
|
||||
let n_entries = dir.len() / 128;
|
||||
if n_entries == 0 {
|
||||
return Err(invalid("empty directory"));
|
||||
}
|
||||
for i in 0..n_entries {
|
||||
cf.entries.push(parse_entry(&dir[i * 128..(i + 1) * 128], version));
|
||||
}
|
||||
if cf.entries[0].kind != EntryKind::Root {
|
||||
return Err(invalid("first directory entry is not the root"));
|
||||
}
|
||||
// Mini FAT and mini stream.
|
||||
if first_minifat <= MAXREGSECT {
|
||||
let mf = cf.read_chain(first_minifat, None)?;
|
||||
cf.minifat = mf.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
|
||||
}
|
||||
let root = &cf.entries[0];
|
||||
if root.start <= MAXREGSECT && root.size > 0 {
|
||||
let size = root.size.min(data.len() as u64) as usize;
|
||||
cf.mini_stream = cf.read_chain(root.start, Some(size))?;
|
||||
}
|
||||
cf.build_tree()?;
|
||||
Ok(cf)
|
||||
}
|
||||
|
||||
/// Sector `n` (a whole sector; a truncated last sector is an error).
|
||||
fn sector(&self, n: u32) -> Result<&'a [u8]> {
|
||||
let start = (n as usize + 1).checked_mul(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
|
||||
let end = start.checked_add(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
|
||||
if n > MAXREGSECT || end > self.data.len() {
|
||||
return Err(invalid(format!("sector {n} is outside the file")));
|
||||
}
|
||||
Ok(&self.data[start..end])
|
||||
}
|
||||
|
||||
/// Concatenate a regular sector chain; `limit` stops once that many bytes are read.
|
||||
fn read_chain(&self, start: u32, limit: Option<usize>) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut s = start;
|
||||
let mut steps = 0usize;
|
||||
while s != ENDOFCHAIN {
|
||||
if s > MAXREGSECT {
|
||||
return Err(invalid(format!("bad sector {s:#x} in a chain")));
|
||||
}
|
||||
steps += 1;
|
||||
if steps > self.fat.len().max(1) {
|
||||
return Err(invalid("sector chain loops"));
|
||||
}
|
||||
let b = self.sector(s)?;
|
||||
out.extend_from_slice(b);
|
||||
if limit.is_some_and(|l| out.len() >= l) {
|
||||
break;
|
||||
}
|
||||
s = *self.fat.get(s as usize).ok_or_else(|| invalid(format!("sector {s} has no FAT entry")))?;
|
||||
if s == FREESECT {
|
||||
return Err(invalid("chain runs into a free sector"));
|
||||
}
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
if out.len() < l {
|
||||
return Err(invalid("stream is truncated"));
|
||||
}
|
||||
out.truncate(l);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_mini_chain(&self, start: u32, size: usize) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(size);
|
||||
let mut s = start;
|
||||
let mut steps = 0usize;
|
||||
while out.len() < size {
|
||||
if s > MAXREGSECT {
|
||||
return Err(invalid("mini stream chain ends early"));
|
||||
}
|
||||
steps += 1;
|
||||
if steps > self.minifat.len().max(1) {
|
||||
return Err(invalid("mini sector chain loops"));
|
||||
}
|
||||
let at = s as usize * MINI_SECTOR_SIZE;
|
||||
let b = self.mini_stream.get(at..at + MINI_SECTOR_SIZE).ok_or_else(|| invalid("mini sector outside the mini stream"))?;
|
||||
out.extend_from_slice(b);
|
||||
s = *self.minifat.get(s as usize).ok_or_else(|| invalid("mini sector has no mini FAT entry"))?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_tree(&mut self) -> Result<()> {
|
||||
let n = self.entries.len();
|
||||
let mut owner = vec![false; n];
|
||||
owner[0] = true;
|
||||
for parent in 0..n {
|
||||
if !matches!(self.entries[parent].kind, EntryKind::Root | EntryKind::Storage) {
|
||||
continue;
|
||||
}
|
||||
let mut kids = Vec::new();
|
||||
// in-order walk of the sibling tree, iterative, cycle-safe
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
let mut cur = self.entries[parent].child;
|
||||
let mut steps = 0;
|
||||
while cur != NOSTREAM || !stack.is_empty() {
|
||||
while cur != NOSTREAM {
|
||||
let i = cur as usize;
|
||||
if i >= n || owner[i] {
|
||||
return Err(invalid(format!("directory entry {cur} is referenced twice or does not exist")));
|
||||
}
|
||||
owner[i] = true;
|
||||
stack.push(cur);
|
||||
cur = self.entries[i].left;
|
||||
steps += 1;
|
||||
if steps > n {
|
||||
return Err(invalid("directory tree loops"));
|
||||
}
|
||||
}
|
||||
if let Some(top) = stack.pop() {
|
||||
kids.push(top as usize);
|
||||
cur = self.entries[top as usize].right;
|
||||
}
|
||||
}
|
||||
self.entries[parent].children = kids;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Entry {
|
||||
&self.entries[0]
|
||||
}
|
||||
|
||||
pub fn entry(&self, id: usize) -> Option<&Entry> {
|
||||
self.entries.get(id)
|
||||
}
|
||||
|
||||
/// Child of storage `parent` named `name` (case-insensitive as in §2.6.4).
|
||||
pub fn child(&self, parent: usize, name: &str) -> Option<usize> {
|
||||
self.entries.get(parent)?.children.iter().copied().find(|&c| compare_names(&self.entries[c].name, name).is_eq())
|
||||
}
|
||||
|
||||
/// Entry at a `/`-separated path below the root (`""` is the root).
|
||||
pub fn find(&self, path: &str) -> Result<usize> {
|
||||
let mut cur = 0;
|
||||
for part in path.split('/').filter(|p| !p.is_empty()) {
|
||||
cur = self.child(cur, part).ok_or_else(|| Error::NotFound(path.to_string()))?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Contents of stream `id`.
|
||||
pub fn read(&self, id: usize) -> Result<Vec<u8>> {
|
||||
let e = self.entries.get(id).ok_or_else(|| Error::NotFound(format!("entry {id}")))?;
|
||||
if e.kind != EntryKind::Stream {
|
||||
return Err(invalid(format!("{:?} is not a stream", e.name)));
|
||||
}
|
||||
let size = usize::try_from(e.size).map_err(|_| invalid("stream too large"))?;
|
||||
if size == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if size as u64 > self.data.len() as u64 {
|
||||
return Err(invalid(format!("stream {:?} is larger than the file", e.name)));
|
||||
}
|
||||
if e.size < self.mini_cutoff { self.read_mini_chain(e.start, size) } else { self.read_chain(e.start, Some(size)) }
|
||||
}
|
||||
|
||||
/// Contents of the stream at `path`.
|
||||
pub fn read_path(&self, path: &str) -> Result<Vec<u8>> {
|
||||
self.read(self.find(path)?)
|
||||
}
|
||||
|
||||
/// Sector size in bytes (512 or 4096).
|
||||
pub fn sector_size(&self) -> usize {
|
||||
self.sector_size
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_entry(b: &[u8], version: u16) -> Entry {
|
||||
let name_len = (u16_at(b, 64) as usize).min(64);
|
||||
let units: Vec<u16> = (0..name_len / 2).map(|i| u16_at(b, i * 2)).take_while(|&u| u != 0).collect();
|
||||
let kind = match b[66] {
|
||||
5 => EntryKind::Root,
|
||||
1 => EntryKind::Storage,
|
||||
2 => EntryKind::Stream,
|
||||
_ => EntryKind::Unknown,
|
||||
};
|
||||
let mut clsid = [0u8; 16];
|
||||
clsid.copy_from_slice(&b[80..96]);
|
||||
let size = u64_at(b, 120);
|
||||
Entry {
|
||||
name: String::from_utf16_lossy(&units),
|
||||
kind,
|
||||
clsid,
|
||||
state_bits: u32_at(b, 96),
|
||||
created: u64_at(b, 100),
|
||||
modified: u64_at(b, 108),
|
||||
start: u32_at(b, 116),
|
||||
// version 3 files may leave garbage in the high 32 bits (§2.6.3)
|
||||
size: if version == 3 { size & 0xFFFF_FFFF } else { size },
|
||||
children: Vec::new(),
|
||||
left: u32_at(b, 68),
|
||||
right: u32_at(b, 72),
|
||||
child: u32_at(b, 76),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
use crate::*;
|
||||
|
||||
fn pattern(n: usize, seed: u8) -> Vec<u8> {
|
||||
(0..n).map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8).collect()
|
||||
}
|
||||
|
||||
/// A tree with mini and regular streams, nested storages and many siblings.
|
||||
fn sample(version: Version, siblings: usize) -> (Vec<u8>, Vec<(String, Vec<u8>)>) {
|
||||
let mut w = Writer::new(version);
|
||||
w.set_clsid(Writer::ROOT, [7; 16]);
|
||||
let mut expect = Vec::new();
|
||||
let a = w.storage(Writer::ROOT, "Header-2", [1; 16]).unwrap();
|
||||
let b = w.storage(a, "Content-3b03", [2; 16]).unwrap();
|
||||
for (i, size) in [0usize, 1, 63, 64, 65, 4095, 4096, 4097, 20_000].iter().enumerate() {
|
||||
let name = format!("s{i}");
|
||||
let data = pattern(*size, i as u8);
|
||||
w.stream(b, &name, data.clone()).unwrap();
|
||||
expect.push((format!("Header-2/Content-3b03/{name}"), data));
|
||||
}
|
||||
for i in 0..siblings {
|
||||
let name = format!("Mobs-1901{{{i:x}}}");
|
||||
let s = w.storage(Writer::ROOT, &name, [3; 16]).unwrap();
|
||||
let data = pattern(10 + i * 7, 99);
|
||||
w.stream(s, "properties", data.clone()).unwrap();
|
||||
expect.push((format!("{name}/properties"), data));
|
||||
}
|
||||
(w.finish(), expect)
|
||||
}
|
||||
|
||||
fn check(bytes: &[u8], expect: &[(String, Vec<u8>)]) {
|
||||
let cf = CompoundFile::open(bytes).unwrap();
|
||||
assert_eq!(cf.root().clsid, [7; 16]);
|
||||
for (path, data) in expect {
|
||||
assert_eq!(&cf.read_path(path).unwrap(), data, "{path}");
|
||||
}
|
||||
let h = cf.find("Header-2").unwrap();
|
||||
assert_eq!(cf.entries[h].clsid, [1; 16]);
|
||||
assert_eq!(cf.entries[h].kind, EntryKind::Storage);
|
||||
// children come back sorted in CFB order
|
||||
for e in &cf.entries {
|
||||
for w in e.children.windows(2) {
|
||||
assert!(compare_names(&cf.entries[w[0]].name, &cf.entries[w[1]].name).is_lt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every path from a node to a nil leaf has the same number of black nodes, and no red node has
|
||||
/// a red child.
|
||||
fn check_red_black(bytes: &[u8]) {
|
||||
let cf = CompoundFile::open(bytes).unwrap();
|
||||
let raw = |i: u32| -> (u32, u32, u8) {
|
||||
let ss = cf.sector_size();
|
||||
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
||||
// the writer stores the directory contiguously
|
||||
let at = (dir_start as usize + 1) * ss + i as usize * 128;
|
||||
let e = &bytes[at..at + 128];
|
||||
(u32::from_le_bytes(e[68..72].try_into().unwrap()), u32::from_le_bytes(e[72..76].try_into().unwrap()), e[67])
|
||||
};
|
||||
fn black_height(raw: &dyn Fn(u32) -> (u32, u32, u8), n: u32, parent_red: bool) -> usize {
|
||||
if n == NOSTREAM {
|
||||
return 1;
|
||||
}
|
||||
let (l, r, c) = raw(n);
|
||||
let red = c == 0;
|
||||
assert!(!(red && parent_red), "red node with a red parent");
|
||||
let hl = black_height(raw, l, red);
|
||||
let hr = black_height(raw, r, red);
|
||||
assert_eq!(hl, hr, "unbalanced black height");
|
||||
hl + usize::from(!red)
|
||||
}
|
||||
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
||||
let ss = cf.sector_size();
|
||||
for i in 0..cf.entries.len() {
|
||||
let at = (dir_start as usize + 1) * ss + i * 128;
|
||||
let child = u32::from_le_bytes(bytes[at + 76..at + 80].try_into().unwrap());
|
||||
if child != NOSTREAM {
|
||||
let (_, _, c) = raw(child);
|
||||
assert_eq!(c, 1, "tree roots are black");
|
||||
black_height(&raw, child, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_v3_and_v4() {
|
||||
for v in [Version::V3, Version::V4] {
|
||||
for n in [0, 1, 2, 3, 7, 8, 40] {
|
||||
let (bytes, expect) = sample(v, n);
|
||||
assert_eq!(bytes.len() % v_sector(v), 0);
|
||||
check(&bytes, &expect);
|
||||
check_red_black(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn v_sector(v: Version) -> usize {
|
||||
if v == Version::V3 { 512 } else { 4096 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_fields() {
|
||||
let (b, _) = sample(Version::V4, 3);
|
||||
assert!(sniff(&b));
|
||||
assert_eq!(&b[24..26], &0x3Eu16.to_le_bytes());
|
||||
assert_eq!(&b[26..28], &4u16.to_le_bytes());
|
||||
assert_eq!(&b[30..32], &12u16.to_le_bytes());
|
||||
let (b, _) = sample(Version::V3, 3);
|
||||
assert_eq!(&b[26..28], &3u16.to_le_bytes());
|
||||
assert_eq!(&b[30..32], &9u16.to_le_bytes());
|
||||
assert_eq!(&b[40..44], &0u32.to_le_bytes(), "version 3 leaves the directory sector count 0");
|
||||
assert_eq!(CompoundFile::open(&b).unwrap().version, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_file_needs_difat() {
|
||||
// > 109 FAT sectors with 512-byte sectors: 109 * 128 sectors * 512 bytes ≈ 7.1 MB
|
||||
let mut w = Writer::new(Version::V3);
|
||||
let big = pattern(7_500_000, 5);
|
||||
w.stream(Writer::ROOT, "big", big.clone()).unwrap();
|
||||
w.stream(Writer::ROOT, "small", vec![1, 2, 3]).unwrap();
|
||||
let b = w.finish();
|
||||
let n_difat = u32::from_le_bytes(b[72..76].try_into().unwrap());
|
||||
assert!(n_difat >= 1);
|
||||
let cf = CompoundFile::open(&b).unwrap();
|
||||
assert_eq!(cf.read_path("big").unwrap(), big);
|
||||
assert_eq!(cf.read_path("small").unwrap(), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert!(compare_names("b", "aa").is_lt(), "shorter first");
|
||||
assert!(compare_names("abc", "ABD").is_lt());
|
||||
assert!(compare_names("Abc", "aBC").is_eq());
|
||||
assert!(validate_name("a/b").is_err());
|
||||
assert!(validate_name(&"x".repeat(32)).is_err());
|
||||
assert!(validate_name(&"x".repeat(31)).is_ok());
|
||||
let mut w = Writer::default();
|
||||
w.stream(Writer::ROOT, "Name", vec![]).unwrap();
|
||||
assert!(matches!(w.stream(Writer::ROOT, "NAME", vec![]), Err(Error::BadName(_))));
|
||||
let s = w.stream(Writer::ROOT, "x", vec![]).unwrap();
|
||||
assert!(w.storage(s, "y", [0; 16]).is_err(), "streams have no children");
|
||||
let cf_bytes = w.finish();
|
||||
let cf = CompoundFile::open(&cf_bytes).unwrap();
|
||||
assert!(cf.find("name").is_ok(), "lookups are case-insensitive");
|
||||
assert!(matches!(cf.find("nope"), Err(Error::NotFound(_))));
|
||||
assert!(cf.read(0).is_err(), "the root is not a stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_names() {
|
||||
let mut w = Writer::default();
|
||||
w.stream(Writer::ROOT, "Ünïcødé ストリーム", vec![9; 10]).unwrap();
|
||||
let b = w.finish();
|
||||
let cf = CompoundFile::open(&b).unwrap();
|
||||
assert_eq!(cf.read_path("üNÏCØDÉ ストリーム").unwrap(), vec![9; 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_cfb() {
|
||||
assert_eq!(CompoundFile::open(b"hello").unwrap_err(), Error::NotCfb);
|
||||
assert_eq!(CompoundFile::open(&[0u8; 600]).unwrap_err(), Error::NotCfb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_never_panics() {
|
||||
let (b, expect) = sample(Version::V3, 20);
|
||||
let mut ok = 0;
|
||||
for cut in (0..b.len()).step_by(29) {
|
||||
if let Ok(cf) = CompoundFile::open(&b[..cut]) {
|
||||
ok += 1;
|
||||
for (path, data) in &expect {
|
||||
if let Ok(d) = cf.read_path(path) {
|
||||
assert_eq!(&d, data, "a stream that reads back is complete");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ok;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_never_panics_or_hangs() {
|
||||
let (b, _) = sample(Version::V3, 12);
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
for _ in 0..1500 {
|
||||
let mut g = b.clone();
|
||||
for _ in 0..6 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
// bias towards the header, FAT and directory (the end of the file)
|
||||
let at = if seed & 1 == 0 { (seed >> 8) as usize % 512 } else { g.len() - 1 - (seed >> 8) as usize % 2048.min(g.len()) };
|
||||
g[at] = (seed >> 40) as u8;
|
||||
}
|
||||
if let Ok(cf) = CompoundFile::open(&g) {
|
||||
for i in 0..cf.entries.len() {
|
||||
let _ = cf.read(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
//! Writing compound files ([MS-CFB] §2.2–2.6).
|
||||
//!
|
||||
//! Layout written by [`Writer::finish`] (sector numbers ascending): the sectors of every stream of
|
||||
//! 4096 bytes or more, the mini stream, the mini FAT, the directory, the FAT, then DIFAT sectors
|
||||
//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
|
||||
//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
|
||||
|
||||
use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
|
||||
|
||||
/// File format version.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Version {
|
||||
/// Version 3: 512-byte sectors.
|
||||
V3,
|
||||
/// Version 4: 4096-byte sectors (AAF's "4K" files).
|
||||
#[default]
|
||||
V4,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
fn sector_size(self) -> usize {
|
||||
match self {
|
||||
Version::V3 => 512,
|
||||
Version::V4 => 4096,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A storage or stream added to a [`Writer`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct NodeId(pub usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Root,
|
||||
Storage,
|
||||
Stream,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Node {
|
||||
name: String,
|
||||
kind: Kind,
|
||||
clsid: [u8; 16],
|
||||
data: Vec<u8>,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Builds a compound file in memory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Writer {
|
||||
version: Version,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
impl Default for Writer {
|
||||
fn default() -> Self {
|
||||
Writer::new(Version::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// The root storage.
|
||||
pub const ROOT: NodeId = NodeId(0);
|
||||
|
||||
pub fn new(version: Version) -> Self {
|
||||
Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
|
||||
}
|
||||
|
||||
fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec<u8>) -> Result<NodeId> {
|
||||
validate_name(name)?;
|
||||
let p = self.nodes.get(parent.0).ok_or_else(|| crate::Error::NotFound(format!("node {}", parent.0)))?;
|
||||
if p.kind == Kind::Stream {
|
||||
return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
|
||||
}
|
||||
if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
|
||||
return Err(crate::Error::BadName(format!("duplicate name {name}")));
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
|
||||
self.nodes[parent.0].children.push(id);
|
||||
Ok(NodeId(id))
|
||||
}
|
||||
|
||||
/// Add a storage below `parent`.
|
||||
pub fn storage(&mut self, parent: NodeId, name: &str, clsid: [u8; 16]) -> Result<NodeId> {
|
||||
self.add(parent, name, Kind::Storage, clsid, Vec::new())
|
||||
}
|
||||
|
||||
/// Add a stream below `parent`.
|
||||
pub fn stream(&mut self, parent: NodeId, name: &str, data: Vec<u8>) -> Result<NodeId> {
|
||||
self.add(parent, name, Kind::Stream, [0; 16], data)
|
||||
}
|
||||
|
||||
/// Set the class id of a storage (or the root).
|
||||
pub fn set_clsid(&mut self, node: NodeId, clsid: [u8; 16]) {
|
||||
if let Some(n) = self.nodes.get_mut(node.0) {
|
||||
n.clsid = clsid;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay the file out and return its bytes.
|
||||
pub fn finish(self) -> Vec<u8> {
|
||||
let ss = self.version.sector_size();
|
||||
let per_sector = ss / 4;
|
||||
// Directory order: depth-first pre-order from the root.
|
||||
let mut order = Vec::with_capacity(self.nodes.len());
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
order.push(n);
|
||||
for &c in self.nodes[n].children.iter().rev() {
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
let mut dir_index = vec![0u32; self.nodes.len()];
|
||||
for (i, &n) in order.iter().enumerate() {
|
||||
dir_index[n] = i as u32;
|
||||
}
|
||||
|
||||
// Stream placement.
|
||||
let mut start = vec![ENDOFCHAIN; self.nodes.len()];
|
||||
let mut next_sector = 0u32;
|
||||
let mut big_chains: Vec<(u32, u32)> = Vec::new(); // (first, count)
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
|
||||
let count = node.data.len().div_ceil(ss) as u32;
|
||||
start[n] = next_sector;
|
||||
big_chains.push((next_sector, count));
|
||||
next_sector += count;
|
||||
}
|
||||
}
|
||||
let mut next_mini = 0u32;
|
||||
let mut mini_chains: Vec<(u32, u32)> = Vec::new();
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
|
||||
let count = node.data.len().div_ceil(MINI_SECTOR_SIZE) as u32;
|
||||
start[n] = next_mini;
|
||||
mini_chains.push((next_mini, count));
|
||||
next_mini += count;
|
||||
}
|
||||
}
|
||||
let mini_bytes = next_mini as usize * MINI_SECTOR_SIZE;
|
||||
let mini_stream_sectors = mini_bytes.div_ceil(ss) as u32;
|
||||
let mini_stream_start = if mini_stream_sectors > 0 { next_sector } else { ENDOFCHAIN };
|
||||
next_sector += mini_stream_sectors;
|
||||
let minifat_sectors = (next_mini as usize * 4).div_ceil(ss) as u32;
|
||||
let minifat_start = if minifat_sectors > 0 { next_sector } else { ENDOFCHAIN };
|
||||
next_sector += minifat_sectors;
|
||||
let dir_sectors = (order.len() * 128).div_ceil(ss) as u32;
|
||||
let dir_start = next_sector;
|
||||
next_sector += dir_sectors;
|
||||
let base = next_sector as usize;
|
||||
let (mut fat_n, mut difat_n) = (1usize, 0usize);
|
||||
loop {
|
||||
let total = base + fat_n + difat_n;
|
||||
let need_fat = total.div_ceil(per_sector);
|
||||
let need_difat = if need_fat > 109 { (need_fat - 109).div_ceil(per_sector - 1) } else { 0 };
|
||||
if need_fat == fat_n && need_difat == difat_n {
|
||||
break;
|
||||
}
|
||||
fat_n = need_fat.max(fat_n);
|
||||
difat_n = need_difat.max(difat_n);
|
||||
}
|
||||
let fat_start = next_sector;
|
||||
let difat_start = fat_start + fat_n as u32;
|
||||
let total = base + fat_n + difat_n;
|
||||
|
||||
// FAT.
|
||||
let mut fat = vec![FREESECT; fat_n * per_sector];
|
||||
let chain = |first: u32, count: u32, fat: &mut [u32]| {
|
||||
for i in 0..count {
|
||||
fat[(first + i) as usize] = if i + 1 == count { ENDOFCHAIN } else { first + i + 1 };
|
||||
}
|
||||
};
|
||||
for &(f, c) in &big_chains {
|
||||
chain(f, c, &mut fat);
|
||||
}
|
||||
if mini_stream_sectors > 0 {
|
||||
chain(mini_stream_start, mini_stream_sectors, &mut fat);
|
||||
}
|
||||
if minifat_sectors > 0 {
|
||||
chain(minifat_start, minifat_sectors, &mut fat);
|
||||
}
|
||||
chain(dir_start, dir_sectors, &mut fat);
|
||||
for i in 0..fat_n {
|
||||
fat[fat_start as usize + i] = FATSECT;
|
||||
}
|
||||
for i in 0..difat_n {
|
||||
fat[difat_start as usize + i] = DIFSECT;
|
||||
}
|
||||
debug_assert!(total <= fat.len());
|
||||
|
||||
// Mini FAT.
|
||||
let mut minifat = vec![FREESECT; minifat_sectors as usize * per_sector];
|
||||
for &(f, c) in &mini_chains {
|
||||
for i in 0..c {
|
||||
minifat[(f + i) as usize] = if i + 1 == c { ENDOFCHAIN } else { f + i + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Directory.
|
||||
let mut tree = vec![(NOSTREAM, NOSTREAM, NOSTREAM, 1u8); self.nodes.len()]; // left, right, child, colour
|
||||
for (p, node) in self.nodes.iter().enumerate() {
|
||||
if node.kind == Kind::Stream || node.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut kids = node.children.clone();
|
||||
kids.sort_by(|&a, &b| compare_names(&self.nodes[a].name, &self.nodes[b].name));
|
||||
let height = usize::BITS - (kids.len() + 1).leading_zeros() - 1; // floor(log2(n + 1))
|
||||
let perfect = (1usize << height) - 1 == kids.len();
|
||||
let root = build_tree(&kids, 0, perfect, height as usize, &mut tree, &dir_index);
|
||||
tree[p].2 = root;
|
||||
}
|
||||
let mut dir = Vec::with_capacity(dir_sectors as usize * ss);
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
let mut e = [0u8; 128];
|
||||
let units: Vec<u16> = node.name.encode_utf16().collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
e[64..66].copy_from_slice(&(((units.len() + 1) * 2) as u16).to_le_bytes());
|
||||
e[66] = match node.kind {
|
||||
Kind::Root => 5,
|
||||
Kind::Storage => 1,
|
||||
Kind::Stream => 2,
|
||||
};
|
||||
let (l, r, c, colour) = tree[n];
|
||||
e[67] = if node.kind == Kind::Root { 1 } else { colour };
|
||||
e[68..72].copy_from_slice(&l.to_le_bytes());
|
||||
e[72..76].copy_from_slice(&r.to_le_bytes());
|
||||
e[76..80].copy_from_slice(&c.to_le_bytes());
|
||||
e[80..96].copy_from_slice(&node.clsid);
|
||||
let (st, size) = match node.kind {
|
||||
Kind::Root => (mini_stream_start, mini_bytes as u64),
|
||||
Kind::Storage => (0, 0),
|
||||
Kind::Stream => (if node.data.is_empty() { ENDOFCHAIN } else { start[n] }, node.data.len() as u64),
|
||||
};
|
||||
e[116..120].copy_from_slice(&st.to_le_bytes());
|
||||
e[120..128].copy_from_slice(&size.to_le_bytes());
|
||||
dir.extend_from_slice(&e);
|
||||
}
|
||||
while dir.len() < dir_sectors as usize * ss {
|
||||
let mut e = [0u8; 128];
|
||||
e[68..80].copy_from_slice(&[0xFF; 12]);
|
||||
dir.extend_from_slice(&e);
|
||||
}
|
||||
|
||||
// Header.
|
||||
let mut out = Vec::with_capacity((total + 1) * ss);
|
||||
out.extend_from_slice(&SIGNATURE);
|
||||
out.extend_from_slice(&[0; 16]);
|
||||
out.extend_from_slice(&0x003Eu16.to_le_bytes());
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 3u16 } else { 4u16 }).to_le_bytes());
|
||||
out.extend_from_slice(&0xFFFEu16.to_le_bytes());
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 9u16 } else { 12u16 }).to_le_bytes());
|
||||
out.extend_from_slice(&6u16.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 6]);
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 0 } else { dir_sectors }).to_le_bytes());
|
||||
out.extend_from_slice(&(fat_n as u32).to_le_bytes());
|
||||
out.extend_from_slice(&dir_start.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&MINI_STREAM_CUTOFF.to_le_bytes());
|
||||
out.extend_from_slice(&minifat_start.to_le_bytes());
|
||||
out.extend_from_slice(&minifat_sectors.to_le_bytes());
|
||||
out.extend_from_slice(&(if difat_n > 0 { difat_start } else { ENDOFCHAIN }).to_le_bytes());
|
||||
out.extend_from_slice(&(difat_n as u32).to_le_bytes());
|
||||
for i in 0..109 {
|
||||
let v = if i < fat_n { fat_start + i as u32 } else { FREESECT };
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.resize(ss, 0);
|
||||
|
||||
// Sectors.
|
||||
let pad = |out: &mut Vec<u8>| {
|
||||
let r = out.len() % ss;
|
||||
if r != 0 {
|
||||
out.resize(out.len() + ss - r, 0);
|
||||
}
|
||||
};
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
|
||||
out.extend_from_slice(&node.data);
|
||||
pad(&mut out);
|
||||
}
|
||||
}
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
|
||||
out.extend_from_slice(&node.data);
|
||||
let r = node.data.len() % MINI_SECTOR_SIZE;
|
||||
if r != 0 {
|
||||
out.resize(out.len() + MINI_SECTOR_SIZE - r, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
pad(&mut out);
|
||||
for v in &minifat {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&dir);
|
||||
for v in &fat {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let mut remaining: Vec<u32> = (109..fat_n).map(|i| fat_start + i as u32).collect();
|
||||
for i in 0..difat_n {
|
||||
let mut s = Vec::with_capacity(ss);
|
||||
let take = remaining.len().min(per_sector - 1);
|
||||
for v in remaining.drain(..take) {
|
||||
s.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
while s.len() < ss - 4 {
|
||||
s.extend_from_slice(&FREESECT.to_le_bytes());
|
||||
}
|
||||
let next = if i + 1 < difat_n { difat_start + i as u32 + 1 } else { ENDOFCHAIN };
|
||||
s.extend_from_slice(&next.to_le_bytes());
|
||||
out.extend_from_slice(&s);
|
||||
}
|
||||
debug_assert_eq!(out.len(), (total + 1) * ss);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Balanced BST of `kids[..]` (sorted). Nodes on the bottom level of an imperfect tree are red,
|
||||
/// so every root-to-leaf path has the same number of black nodes.
|
||||
fn build_tree(kids: &[usize], depth: usize, perfect: bool, height: usize, tree: &mut [(u32, u32, u32, u8)], dir_index: &[u32]) -> u32 {
|
||||
if kids.is_empty() {
|
||||
return NOSTREAM;
|
||||
}
|
||||
let mid = kids.len() / 2;
|
||||
let n = kids[mid];
|
||||
let left = build_tree(&kids[..mid], depth + 1, perfect, height, tree, dir_index);
|
||||
let right = build_tree(&kids[mid + 1..], depth + 1, perfect, height, tree, dir_index);
|
||||
tree[n].0 = left;
|
||||
tree[n].1 = right;
|
||||
tree[n].3 = if !perfect && depth == height { 0 } else { 1 };
|
||||
dir_index[n]
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ filmcraft-scopes = { workspace = true }
|
|||
filmcraft-text = { workspace = true }
|
||||
filmcraft-audio-dsp = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-format = { workspace = true }
|
||||
filmcraft-interchange = { workspace = true }
|
||||
filmcraft-captions = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
//! File ▸ Export ▸ AAF… / OMF… and File ▸ Import of AAF / OMF documents.
|
||||
//!
|
||||
//! The interchange crate writes the documents; this module prepares their audio essence the way
|
||||
//! Premiere's AAF and OMF export dialogs offer it: embedded in the document or written as separate
|
||||
//! WAV / AIFF files next to it, at a chosen sample rate and bit depth, trimmed to the used ranges
|
||||
//! plus handles (or whole), with clip effects rendered in, broken out to mono; and for AAF an
|
||||
//! optional video mixdown rendered to one file.
|
||||
//!
|
||||
//! `file.exportAaf {path, sequence?, mixdownVideo?, breakoutToMono?, audio?: "embedded"|"separate"|
|
||||
//! "linked", audioFormat?: "wav"|"aiff"|"mxf" (OP-Atom, AAF only), sampleRate?, bitDepth?, trimAudio?, handles? (frames),
|
||||
//! renderAudioEffects?, smallSectors?}`
|
||||
//!
|
||||
//! `file.exportOmf {path, sequence?, audio?: "embedded"|"separate", audioFormat?, sampleRate?,
|
||||
//! bitDepth?, trimAudio? (default on), handles?, renderAudioEffects?, breakoutToMono?}`
|
||||
//!
|
||||
//! `file.importAaf {path}` (also `file.import` of `.aaf` / `.omf` files).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use filmcraft_interchange::essence::{AudioEssence, AudioNeed, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, audio_needs};
|
||||
use filmcraft_project::{ItemId, Project, SequenceSettings, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
use crate::commands::{bad, bool_p, str_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Where exported audio goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AudioMode {
|
||||
/// Inside the document.
|
||||
Embedded,
|
||||
/// Separate WAV / AIFF files next to the document.
|
||||
Separate,
|
||||
/// The original media files (AAF only; untrimmed, unrendered).
|
||||
Linked,
|
||||
}
|
||||
|
||||
/// The audio settings of the AAF / OMF export dialogs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPlan {
|
||||
pub mode: AudioMode,
|
||||
pub aiff: bool,
|
||||
/// Separate files as OP-Atom MXF (AAF only; Avid-style consolidated media).
|
||||
pub mxf: bool,
|
||||
pub sample_rate: u32,
|
||||
pub bits: u16,
|
||||
pub trim: bool,
|
||||
pub handles: Tick,
|
||||
pub render_effects: bool,
|
||||
pub breakout: bool,
|
||||
}
|
||||
|
||||
fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Result<AudioPlan> {
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let mode = match str_p(p, "audio").unwrap_or("embedded") {
|
||||
"embedded" | "embed" | "encapsulate" => AudioMode::Embedded,
|
||||
"separate" | "separateAudio" => AudioMode::Separate,
|
||||
"linked" | "link" if !omf => AudioMode::Linked,
|
||||
other => return Err(bad(cmd, format!("unknown audio mode {other:?} (embedded, separate{})", if omf { "" } else { " or linked" }))),
|
||||
};
|
||||
let (aiff, mxf) = match str_p(p, "audioFormat").unwrap_or("wav") {
|
||||
"wav" | "bwf" | "broadcastWave" => (false, false),
|
||||
"aiff" | "aif" => (true, false),
|
||||
"mxf" | "opatom" if !omf => (false, true),
|
||||
other => return Err(bad(cmd, format!("unknown audio format {other:?} (wav, aiff{})", if omf { "" } else { " or mxf" }))),
|
||||
};
|
||||
let sample_rate = u64_p(p, "sampleRate").map(|r| r as u32).unwrap_or(q.settings.sample_rate.max(1));
|
||||
if !(8_000..=192_000).contains(&sample_rate) {
|
||||
return Err(bad(cmd, "sampleRate must be 8000–192000"));
|
||||
}
|
||||
let bits = u64_p(p, "bitDepth").unwrap_or(16) as u16;
|
||||
if bits != 16 && bits != 24 {
|
||||
return Err(bad(cmd, "bitDepth must be 16 or 24"));
|
||||
}
|
||||
let frames = u64_p(p, "handles").unwrap_or(if omf { 30 } else { 0 }) as i64;
|
||||
Ok(AudioPlan {
|
||||
mode,
|
||||
aiff,
|
||||
mxf,
|
||||
sample_rate,
|
||||
bits,
|
||||
trim: bool_p(p, "trimAudio").unwrap_or(omf),
|
||||
handles: q.settings.frame_rate.tick_of(frames),
|
||||
render_effects: bool_p(p, "renderAudioEffects").unwrap_or(false),
|
||||
breakout: bool_p(p, "breakoutToMono").unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn quantize(planar: &[&Vec<f32>], bits: u16) -> Vec<u8> {
|
||||
let n = planar.first().map_or(0, |c| c.len());
|
||||
let bps = if bits >= 24 { 3 } else { 2 };
|
||||
let mut out = Vec::with_capacity(n * planar.len() * bps);
|
||||
for i in 0..n {
|
||||
for c in planar {
|
||||
let x = c[i].clamp(-1.0, 1.0);
|
||||
if bps == 3 {
|
||||
out.extend_from_slice(&((x * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
|
||||
} else {
|
||||
out.extend_from_slice(&((x * 32_767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec<Vec<f32>> {
|
||||
let bps = bits.div_ceil(8) as usize;
|
||||
let ch = channels.max(1);
|
||||
let n = pcm.len() / (bps * ch).max(1);
|
||||
let mut out = vec![Vec::with_capacity(n); ch];
|
||||
for i in 0..n {
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
let s = &pcm[(i * ch + c) * bps..(i * ch + c + 1) * bps];
|
||||
let v = match bps {
|
||||
1 => (s[0] as f32 - 128.0) / 128.0,
|
||||
2 => i16::from_le_bytes([s[0], s[1]]) as f32 / 32_768.0,
|
||||
3 => (i32::from_le_bytes([0, s[0], s[1], s[2]]) >> 8) as f32 / 8_388_608.0,
|
||||
_ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32 / 2_147_483_648.0,
|
||||
};
|
||||
o.push(v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a media range at `sample_rate` (planar).
|
||||
fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32>>> {
|
||||
let src = s
|
||||
.media
|
||||
.full_res_source(&s.project, need.item, &*s.services)
|
||||
.ok_or_else(|| EngineError::Other(format!("media {} is offline", need.path.as_deref().unwrap_or("?"))))?;
|
||||
let start = need.start.to_units_floor(sample_rate as i64);
|
||||
let frames = (need.end.to_units_floor(sample_rate as i64) - start).max(0) as usize;
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize];
|
||||
let chunk = sample_rate as usize * 10;
|
||||
let mut done = 0usize;
|
||||
while done < frames {
|
||||
let n = chunk.min(frames - done);
|
||||
let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
match b.channels.get(c).or(b.channels.first()) {
|
||||
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
|
||||
None => o.extend(std::iter::repeat_n(0.0, n)),
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render one clip (with its effects, gain and volume) over a media range, through the export
|
||||
/// pipeline's audio mixer.
|
||||
fn render_clip(s: &Session, seq: ItemId, need: &AudioNeed, sample_rate: u32, bits: u16) -> Result<Vec<Vec<f32>>> {
|
||||
let EssenceKey::Clip(clip_id) = need.key else { return decode(s, need, sample_rate) };
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let (track, c) = q
|
||||
.tracks(TrackKind::Audio)
|
||||
.iter()
|
||||
.find_map(|t| t.items.iter().find(|c| c.id == clip_id).map(|c| (t, c.clone())))
|
||||
.ok_or_else(|| EngineError::Other("clip not found".into()))?;
|
||||
let mut p: Project = (*s.project).clone();
|
||||
let settings = SequenceSettings { sample_rate, ..q.settings.clone() };
|
||||
let rs = p.new_sequence("AAF audio render", settings, 0, 1, None);
|
||||
let dur = need.end - need.start;
|
||||
let mut c = c;
|
||||
c.start = Tick::ZERO;
|
||||
c.source_in = need.start;
|
||||
c.duration = dur;
|
||||
c.speed = 1.0;
|
||||
c.reverse = false;
|
||||
c.link = None;
|
||||
let channels = match track.channels {
|
||||
filmcraft_project::AudioChannels::Mono => 1,
|
||||
filmcraft_project::AudioChannels::Surround51 => 6,
|
||||
_ => 2,
|
||||
};
|
||||
if let Some(t) = p.sequence_mut(rs).and_then(|r| r.audio_tracks.first_mut()) {
|
||||
t.channels = track.channels;
|
||||
t.items = vec![c];
|
||||
}
|
||||
let buf: Arc<Mutex<Vec<u8>>> = Arc::default();
|
||||
let sink_buf = buf.clone();
|
||||
let mut settings = filmcraft_export::ExportSettings {
|
||||
format: filmcraft_export::Format::Wav,
|
||||
path: "render.wav".into(),
|
||||
range: Some(TimeRange::new(Tick::ZERO, dur)),
|
||||
part_of_batch: true,
|
||||
sink: Some(filmcraft_export::OutputSink(Arc::new(move |_: &str, d: Vec<u8>| {
|
||||
*sink_buf.lock().unwrap_or_else(|e| e.into_inner()) = d;
|
||||
Ok(())
|
||||
}))),
|
||||
..Default::default()
|
||||
};
|
||||
settings.audio.sample_rate = Some(sample_rate);
|
||||
settings.audio.channels = channels;
|
||||
settings.audio.bits = bits;
|
||||
let project = Arc::new(p);
|
||||
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
|
||||
filmcraft_export::export(&project, rs, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let wav = std::mem::take(&mut *buf.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let (pcm, ch, _, b) = filmcraft_interchange::wav::parse_wav(&wav).ok_or_else(|| EngineError::Other("audio render produced no WAVE data".into()))?;
|
||||
Ok(dequantize(&pcm, ch as usize, b))
|
||||
}
|
||||
|
||||
fn safe_name(s: &str) -> String {
|
||||
let n: String = s.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
|
||||
let n = n.trim().trim_matches('.').to_string();
|
||||
if n.is_empty() { "audio".into() } else { n }
|
||||
}
|
||||
|
||||
fn ensure_dir(s: &Session, dir: &str) {
|
||||
if !s.services.export_in_memory() && !dir.is_empty() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the audio essence of an export; returns the essence and the files written.
|
||||
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let needs =
|
||||
audio_needs(&s.project, seq, &NeedOptions { handles: plan.handles, per_clip: plan.render_effects, whole_media: !plan.trim && !plan.render_effects });
|
||||
let mut essence = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for need in &needs {
|
||||
let planar = if plan.render_effects { render_clip(s, seq, need, plan.sample_rate, plan.bits)? } else { decode(s, need, plan.sample_rate)? };
|
||||
let frames = planar.first().map_or(0, Vec::len) as u64;
|
||||
let groups: Vec<(Option<u32>, Vec<&Vec<f32>>)> =
|
||||
if plan.breakout { planar.iter().enumerate().map(|(c, x)| (Some(c as u32), vec![x])).collect() } else { vec![(None, planar.iter().collect())] };
|
||||
let base = match need.key {
|
||||
EssenceKey::Clip(c) => {
|
||||
let name = s.project.sequence(seq).and_then(|q| q.find_item(c)).map(|(_, ti)| ti.name.clone()).unwrap_or_default();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
need.path
|
||||
.as_deref()
|
||||
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
|
||||
.as_deref()
|
||||
.unwrap_or("audio"),
|
||||
),
|
||||
};
|
||||
for (ch, chans) in groups {
|
||||
let pcm = quantize(&chans, plan.bits);
|
||||
let n = chans.len() as u32;
|
||||
let data = match plan.mode {
|
||||
AudioMode::Embedded => EssenceData::Embedded(pcm),
|
||||
AudioMode::Separate | AudioMode::Linked => {
|
||||
let ext = if plan.mxf {
|
||||
"mxf"
|
||||
} else if plan.aiff {
|
||||
"aif"
|
||||
} else {
|
||||
"wav"
|
||||
};
|
||||
let stem = match ch {
|
||||
Some(c) => format!("{base}_A{}", c + 1),
|
||||
None => base.clone(),
|
||||
};
|
||||
let mut name = format!("{stem}.{ext}");
|
||||
let mut k = 1;
|
||||
while !used.insert(name.to_ascii_lowercase()) {
|
||||
k += 1;
|
||||
name = format!("{stem} ({k}).{ext}");
|
||||
}
|
||||
let path = if media_dir.is_empty() { name.clone() } else { format!("{media_dir}/{name}") };
|
||||
let bytes = if plan.mxf {
|
||||
let bps = plan.bits.div_ceil(8) as usize;
|
||||
let samples: Vec<i32> = pcm
|
||||
.chunks_exact(bps)
|
||||
.map(|b| if bps == 3 { i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8 } else { i16::from_le_bytes([b[0], b[1]]) as i32 })
|
||||
.collect();
|
||||
let opts = filmcraft_mxf::OpAtomPcm {
|
||||
sample_rate: plan.sample_rate,
|
||||
bits: plan.bits as u32,
|
||||
channels: n,
|
||||
edit_rate: None,
|
||||
ids: filmcraft_mxf::PackageIds::from_seed(&path, &name),
|
||||
timecode: None,
|
||||
};
|
||||
filmcraft_mxf::write_opatom_pcm(&opts, &samples).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
} else if plan.aiff {
|
||||
filmcraft_interchange::wav::aiff_file(&pcm, n as u16, plan.sample_rate, plan.bits)
|
||||
} else {
|
||||
filmcraft_interchange::wav::wav_file(&pcm, n as u16, plan.sample_rate, plan.bits)
|
||||
};
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
files.push(path.clone());
|
||||
EssenceData::File { path }
|
||||
}
|
||||
};
|
||||
essence.push(AudioEssence {
|
||||
key: need.key,
|
||||
channel: ch,
|
||||
start: need.start,
|
||||
frames,
|
||||
sample_rate: plan.sample_rate,
|
||||
bits: plan.bits,
|
||||
channels: n,
|
||||
data,
|
||||
effects_rendered: plan.render_effects,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok((essence, files))
|
||||
}
|
||||
|
||||
/// Render the video of `seq` to one file for an AAF video mixdown.
|
||||
fn mixdown(s: &Session, seq: ItemId, path: &str) -> Result<MixdownVideo> {
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let dur = q.duration();
|
||||
if dur <= Tick::ZERO {
|
||||
return Err(EngineError::Other("the sequence is empty".into()));
|
||||
}
|
||||
let format = if path.to_ascii_lowercase().ends_with(".mxf") {
|
||||
filmcraft_export::Format::from_name("mxf-opatom").unwrap_or(filmcraft_export::Format::DnxHr)
|
||||
} else {
|
||||
filmcraft_export::Format::DnxHr
|
||||
};
|
||||
let mut settings = filmcraft_export::ExportSettings {
|
||||
format,
|
||||
path: path.to_string(),
|
||||
range: Some(TimeRange::new(Tick::ZERO, dur)),
|
||||
include_audio: false,
|
||||
part_of_batch: true,
|
||||
dnx_profile: "hq".into(),
|
||||
..Default::default()
|
||||
};
|
||||
if s.services.export_in_memory() {
|
||||
let services = s.services.clone();
|
||||
settings.sink = Some(filmcraft_export::OutputSink(Arc::new(move |p: &str, d: Vec<u8>| services.write_file(p, &d))));
|
||||
}
|
||||
let project = s.project.clone();
|
||||
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
|
||||
filmcraft_export::export(&project, seq, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(format!("video mixdown: {e}")))?;
|
||||
Ok(MixdownVideo { path: path.to_string(), start: Tick::ZERO, duration: dur, width: q.settings.width, height: q.settings.height })
|
||||
}
|
||||
|
||||
fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
|
||||
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
|
||||
}
|
||||
|
||||
fn split(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Export".into());
|
||||
(dir, stem)
|
||||
}
|
||||
|
||||
fn join(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }
|
||||
}
|
||||
|
||||
/// `file.exportAaf`
|
||||
pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportAaf";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, false)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Media"));
|
||||
let mut files = Vec::new();
|
||||
if plan.mode != AudioMode::Embedded || bool_p(p, "mixdownVideo").unwrap_or(false) {
|
||||
ensure_dir(s, &media_dir);
|
||||
}
|
||||
let (essence, written) = prepare_audio(s, seq, &plan, &media_dir)?;
|
||||
files.extend(written);
|
||||
let mixdown_video = if bool_p(p, "mixdownVideo").unwrap_or(false) {
|
||||
let ext = if str_p(p, "mixdownFormat") == Some("mxf") { "mxf" } else { "mov" };
|
||||
let m = mixdown(s, seq, &join(&media_dir, &format!("{} Video Mixdown.{ext}", safe_name(&stem))))?;
|
||||
files.push(m.path.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let opts = filmcraft_interchange::aaf::AafOptions {
|
||||
name: s.project.item(seq).map(|i| i.name.clone()),
|
||||
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: false, essence, mixdown_video },
|
||||
small_sectors: bool_p(p, "smallSectors").unwrap_or(false),
|
||||
};
|
||||
let (bytes, report) = filmcraft_interchange::aaf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
|
||||
}
|
||||
|
||||
/// `file.exportOmf`
|
||||
pub fn export_omf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportOmf";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, true)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Audio Files"));
|
||||
if plan.mode == AudioMode::Separate {
|
||||
ensure_dir(s, &media_dir);
|
||||
}
|
||||
let (essence, files) = prepare_audio(s, seq, &plan, &media_dir)?;
|
||||
let opts = filmcraft_interchange::omf::OmfOptions {
|
||||
name: str_p(p, "title").map(str::to_string).or_else(|| s.project.item(seq).map(|i| i.name.clone())),
|
||||
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: true, essence, mixdown_video: None },
|
||||
};
|
||||
let (bytes, report) = filmcraft_interchange::omf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if bytes.len() as u64 > i32::MAX as u64 {
|
||||
return Err(EngineError::Other("the OMF file would exceed 2 GB; use separate audio files or trim the audio".into()));
|
||||
}
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
|
||||
}
|
||||
|
||||
/// `file.importAaf` (and OMF): import a document as sequences.
|
||||
pub fn import_document(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let format = crate::interchange::detect(&path, &bytes).filter(|f| matches!(f, filmcraft_interchange::Format::Aaf | filmcraft_interchange::Format::Omf));
|
||||
let format = format.ok_or_else(|| bad(cmd, format!("{path} is not an AAF or OMF file")))?;
|
||||
crate::interchange::import(s, &path, &bytes, format)
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
//! File ▸ Export ▸ AAF / OMF and their import, end to end with real media written by our encoders.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_project::{ItemId, ItemKind, MediaRef, Param, ParamValue, TrackKind};
|
||||
use filmcraft_time::FrameRate;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::{make_movie, session_with, tmp_dir};
|
||||
|
||||
/// Two 2-second ProRes + PCM movies cut back to back on V1/A1 (24 fps).
|
||||
fn setup(name: &str) -> (Session, Vec<ItemId>, ItemId, std::path::PathBuf) {
|
||||
let dir = tmp_dir(name);
|
||||
let a = dir.join("a.mov");
|
||||
let b = dir.join("b.mov");
|
||||
make_movie(&a, filmcraft_media::DemoScene::Aurora, 64, 36, 48);
|
||||
make_movie(&b, filmcraft_media::DemoScene::Plasma, 64, 36, 48);
|
||||
let (mut s, items, seq) = session_with(&[&a, &b]);
|
||||
// trim both clips so handles exist: A plays media 12..36, B 6..30
|
||||
let rate = FrameRate::FPS_24;
|
||||
let mut p = (*s.project).clone();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
for t in q.all_tracks_mut() {
|
||||
for (i, c) in t.items.iter_mut().enumerate() {
|
||||
c.start = rate.tick_of(i as i64 * 24);
|
||||
c.source_in = rate.tick_of(if i == 0 { 12 } else { 6 });
|
||||
c.duration = rate.tick_of(24);
|
||||
}
|
||||
}
|
||||
s.project = Arc::new(p);
|
||||
(s, items, seq, dir)
|
||||
}
|
||||
|
||||
fn audio_of(s: &Session, item: ItemId, start: i64, n: usize) -> Vec<Vec<f32>> {
|
||||
let src = s.media.full_res_source(&s.project, item, &*s.services).expect("online");
|
||||
src.audio(start, n, 48_000).unwrap().channels
|
||||
}
|
||||
|
||||
fn imported_seq(r: &serde_json::Value) -> ItemId {
|
||||
let docs = r["documents"].as_array().cloned().unwrap_or_default();
|
||||
let seq = r["sequences"][0].as_u64().or_else(|| docs.first().and_then(|d| d["sequences"][0].as_u64())).unwrap_or_else(|| panic!("no sequence in {r}"));
|
||||
ItemId(seq)
|
||||
}
|
||||
|
||||
fn path_of(s: &Session, item: ItemId) -> String {
|
||||
match &s.project.item(item).unwrap().kind {
|
||||
ItemKind::Media(m) => match &m.media {
|
||||
MediaRef::File { path } => path.clone(),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
|
||||
let (mut s, items, seq, dir) = setup("aaf-embed");
|
||||
let path = dir.join("cut.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "embedded", "trimAudio": true, "handles": 6, "sequence": seq.0})).unwrap();
|
||||
assert!(r["mediaFiles"].as_array().unwrap().is_empty(), "{r}");
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap().clone();
|
||||
let orig = s.project.sequence(seq).unwrap().clone();
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
let a: Vec<_> = q.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
let b: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
assert_eq!(a, b, "{kind:?}");
|
||||
}
|
||||
// video links to the original movies, audio to WAV files extracted next to the AAF
|
||||
assert_eq!(path_of(&s, q.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
for (c, oc) in q.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
assert!(!s.project.item(c.item).unwrap().as_media().unwrap().offline);
|
||||
// trimmed: the essence starts 6 frames (handles) before the clip
|
||||
assert_eq!(c.source_in, FrameRate::FPS_24.tick_of(6));
|
||||
let got = audio_of(&s, c.item, c.source_in.to_units_floor(48_000), 4800);
|
||||
let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 4800);
|
||||
for ch in 0..2 {
|
||||
for i in 0..4800 {
|
||||
assert!((got[ch][i] - want[ch][i]).abs() <= 1.0 / 32_768.0 + 1e-6, "ch {ch} sample {i}: {} vs {}", got[ch][i], want[ch][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_separate_aiff_breakout_and_linked() {
|
||||
let (mut s, items, seq, dir) = setup("aaf-separate");
|
||||
let path = dir.join("sep.aaf").to_string_lossy().into_owned();
|
||||
let r = s
|
||||
.execute(
|
||||
"file.exportAaf",
|
||||
json!({"path": path, "audio": "separate", "audioFormat": "aiff", "bitDepth": 24, "breakoutToMono": true, "trimAudio": true, "handles": 0}),
|
||||
)
|
||||
.unwrap();
|
||||
let files: Vec<String> = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 4, "two media × two channels: {r}");
|
||||
for f in &files {
|
||||
let b = std::fs::read(f).unwrap();
|
||||
let (pcm, ch, sr, bits) = filmcraft_interchange::wav::parse_aiff(&b).unwrap();
|
||||
assert_eq!((ch, sr, bits), (1, 48_000, 24));
|
||||
assert_eq!(pcm.len(), 24 * 2000 * 3, "one second of mono 24-bit audio (no handles)");
|
||||
}
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let mono: Vec<_> = q.audio_tracks.iter().filter(|t| !t.items.is_empty()).collect();
|
||||
assert_eq!(mono.len(), 2);
|
||||
assert!(mono.iter().all(|t| t.channels == filmcraft_project::AudioChannels::Mono));
|
||||
|
||||
// Avid-style consolidated OP-Atom MXF audio: our MXF reader opens every file
|
||||
let path = dir.join("mxf.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "separate", "audioFormat": "mxf", "breakoutToMono": true, "trimAudio": true})).unwrap();
|
||||
let files: Vec<String> = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 4);
|
||||
for f in &files {
|
||||
let b = std::fs::read(f).unwrap();
|
||||
let m = filmcraft_mxf::open(&b).unwrap();
|
||||
assert_eq!(m.operational_pattern.name(), "OP-Atom");
|
||||
let t = m.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
|
||||
assert_eq!(m.tracks[t].stored_sample_frames(), 48_000);
|
||||
}
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let a = &s.project.sequence(n).unwrap().audio_tracks[0].items[0];
|
||||
assert!(path_of(&s, a.item).ends_with(".mxf"));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline, "the MXF audio links");
|
||||
|
||||
// linked: no media is written, the document points at the original movies
|
||||
let path = dir.join("linked.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "linked", "sequence": seq.0})).unwrap();
|
||||
assert!(r["mediaFiles"].as_array().unwrap().is_empty());
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let a = &q.audio_tracks[0].items[0];
|
||||
assert_eq!(path_of(&s, a.item), path_of(&s, items[0]));
|
||||
assert_eq!(a.source_in, FrameRate::FPS_24.tick_of(12));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_video_mixdown() {
|
||||
let (mut s, _items, seq, dir) = setup("aaf-mixdown");
|
||||
let path = dir.join("mix.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "mixdownVideo": true, "audio": "linked", "sequence": seq.0})).unwrap();
|
||||
let files = r["mediaFiles"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let mov = files[0].as_str().unwrap();
|
||||
assert!(mov.ends_with("Video Mixdown.mov") && std::path::Path::new(mov).exists());
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), 1);
|
||||
let v = &q.video_tracks[0].items[0];
|
||||
assert_eq!(v.duration, FrameRate::FPS_24.tick_of(48));
|
||||
assert_eq!(path_of(&s, v.item), mov);
|
||||
let m = s.project.item(v.item).unwrap().as_media().unwrap();
|
||||
assert!(!m.offline);
|
||||
assert_eq!(m.info.video.as_ref().map(|v| (v.width, v.height)), Some((64, 36)));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omf_renders_clip_effects_into_embedded_audio() {
|
||||
let (mut s, _items, seq, dir) = setup("omf-render");
|
||||
// −6.0206 dB on the first audio clip: half amplitude
|
||||
let mut p = (*s.project).clone();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
q.audio_tracks[0].items[0].effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.020_599_913)));
|
||||
s.project = Arc::new(p);
|
||||
let orig = s.project.sequence(seq).unwrap().clone();
|
||||
let path = dir.join("mix.omf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportOmf", json!({"path": path, "renderAudioEffects": true, "handles": 0, "sequence": seq.0})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 48_000 * 2 * 2, "audio is encapsulated: {r}");
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap().clone();
|
||||
assert!(q.video_tracks.iter().all(|t| t.items.is_empty()));
|
||||
let a = &q.audio_tracks[0].items;
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(
|
||||
a.iter().map(|c| (c.start, c.duration)).collect::<Vec<_>>(),
|
||||
orig.audio_tracks[0].items.iter().map(|c| (c.start, c.duration)).collect::<Vec<_>>()
|
||||
);
|
||||
// no gain left on the clips: it is in the samples
|
||||
assert!(a.iter().all(|c| c.effect("volume").and_then(|e| e.param("level")).is_none_or(|p| p.value.as_f64() == Some(0.0))));
|
||||
let oc = &orig.audio_tracks[0].items[0];
|
||||
let got = audio_of(&s, a[0].item, a[0].source_in.to_units_floor(48_000), 2400);
|
||||
let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 2400);
|
||||
let peak = want[0].iter().fold(0f32, |m, x| m.max(x.abs()));
|
||||
assert!(peak > 0.01, "the demo scene has audio");
|
||||
for i in 0..2400 {
|
||||
assert!((got[0][i] - want[0][i] * 0.5).abs() < 2e-3, "sample {i}: {} vs {}", got[0][i], want[0][i] * 0.5);
|
||||
}
|
||||
// the second clip is unchanged
|
||||
let oc2 = &orig.audio_tracks[0].items[1];
|
||||
let got = audio_of(&s, a[1].item, a[1].source_in.to_units_floor(48_000), 2400);
|
||||
let want = audio_of(&s, oc2.item, oc2.source_in.to_units_floor(48_000), 2400);
|
||||
for i in 0..2400 {
|
||||
assert!((got[0][i] - want[0][i]).abs() < 2e-3);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_registered_with_menus_and_validate_params() {
|
||||
for (id, menu) in [("file.exportAaf", &["File", "Export"][..]), ("file.exportOmf", &["File", "Export"][..]), ("file.importAaf", &[][..])] {
|
||||
let c = crate::commands::find(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(c.menu, menu);
|
||||
}
|
||||
let (mut s, _, seq, dir) = setup("aaf-params");
|
||||
let path = dir.join("x.aaf").to_string_lossy().into_owned();
|
||||
assert!(s.execute("file.exportAaf", json!({"sequence": seq.0})).is_err(), "path is required");
|
||||
assert!(s.execute("file.exportAaf", json!({"path": path, "bitDepth": 20})).is_err());
|
||||
assert!(s.execute("file.exportOmf", json!({"path": path, "audio": "linked"})).is_err(), "OMF has no linked mode");
|
||||
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -752,6 +752,25 @@ fn build() -> Vec<CommandSpec> {
|
|||
q["format"] = json!("otio");
|
||||
crate::interchange::export(s, &q)
|
||||
}),
|
||||
cmd!(
|
||||
"file.exportAaf",
|
||||
"AAF…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"sequence":id?,"mixdownVideo":bool?,"mixdownFormat":"mov|mxf"?,"breakoutToMono":bool?,"audio":"embedded|separate|linked"?,"audioFormat":"wav|aiff|mxf"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"smallSectors":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| crate::aaf_omf::export_aaf(s, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.exportOmf",
|
||||
"OMF…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"sequence":id?,"title":str?,"audio":"embedded|separate"?,"audioFormat":"wav|aiff"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"breakoutToMono":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| crate::aaf_omf::export_omf(s, p)
|
||||
),
|
||||
cmd!("file.importAaf", "Import AAF…", [], None, r#"{"path":str}"#, always, |s, p| crate::aaf_omf::import_document(s, p, "file.importAaf")),
|
||||
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| {
|
||||
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;
|
||||
write_project(s, &path, true)
|
||||
|
|
@ -862,7 +881,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
|
|
|
|||
|
|
@ -124,6 +124,18 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
assert_eq!((v["width"].as_u64().unwrap(), v["height"].as_u64().unwrap()), (640, 360));
|
||||
continue;
|
||||
}
|
||||
"mxf-opatom" => {
|
||||
// Avid style: picture only in Demo.mxf, one mono PCM file per channel
|
||||
assert_eq!(fmt_name, "mxf", "{name}");
|
||||
assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}");
|
||||
assert_eq!(stream(&j, "video").unwrap()["codec_name"], "dnxhd", "{name}");
|
||||
for k in 1..=2 {
|
||||
let a = probe(&format!("{}_A{k}.mxf", out.trim_end_matches(".mxf"))).unwrap();
|
||||
let a = stream(&a, "audio").unwrap();
|
||||
assert_eq!((a["codec_name"].as_str(), a["channels"].as_u64()), (Some("pcm_s24le"), Some(1)), "{name} A{k}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let v = stream(&j, "video").unwrap_or_else(|| panic!("{name}: no video"));
|
||||
|
|
@ -167,6 +179,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
assert!((vbr / (mbps * px) - 1.0).abs() < 0.25, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
"mxf-op1a" => {
|
||||
assert_eq!(fmt_name, "mxf", "{name}");
|
||||
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
|
||||
"proRes" => "prores",
|
||||
"h264" => "h264",
|
||||
_ => "dnxhd",
|
||||
};
|
||||
assert_eq!(v["codec_name"], codec, "{name}");
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
other => panic!("{name}: unexpected format {other}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +244,7 @@ fn user_presets_persist_with_favourites_import_and_export() {
|
|||
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(favs, 2);
|
||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(hits, 4);
|
||||
assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
// persisted: a fresh session with the same data directory sees both
|
||||
let mut s2 = demo();
|
||||
s2.export_presets.set_dir(&dir.0);
|
||||
|
|
|
|||
|
|
@ -209,6 +209,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
|||
if let Some(v) = str_p(p, "dnxProfile") {
|
||||
settings.dnx_profile = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "mxfVideoCodec") {
|
||||
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
|
||||
}
|
||||
if let Some(v) = bool_p(p, "sdr") {
|
||||
settings.sdr = v;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO) ↔ the session's project.
|
||||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
|
|||
/// The interchange format of a file, if it is one (by content, with the extension as a hint).
|
||||
pub fn detect(path: &str, bytes: &[u8]) -> Option<Format> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio")) {
|
||||
if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio" | "aaf" | "omf" | "omfi")) {
|
||||
return None;
|
||||
}
|
||||
filmcraft_interchange::detect(bytes, ext.as_deref())
|
||||
|
|
@ -29,7 +29,21 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
|||
name: p.file_stem().map(|n| n.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let (fragment, report) = filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (fragment, report) = match format {
|
||||
// AAF / OMF may embed audio: write it next to the document (the media items point there)
|
||||
Format::Aaf | Format::Omf => {
|
||||
let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
|
||||
let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
for m in extracted {
|
||||
if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
|
||||
}
|
||||
(fragment, report)
|
||||
}
|
||||
_ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?,
|
||||
};
|
||||
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
||||
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
|
||||
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
|
||||
|
|
@ -100,6 +114,8 @@ pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"edl" => Format::Edl,
|
||||
"fcpxml" => Format::Fcpxml,
|
||||
"otio" => Format::Otio,
|
||||
"aaf" => Format::Aaf,
|
||||
"omf" => Format::Omf,
|
||||
_ => Format::Fcp7Xml,
|
||||
};
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots (cheap
|
||||
//! thanks to structural sharing of untouched items).
|
||||
|
||||
pub mod aaf_omf;
|
||||
pub mod autosave;
|
||||
pub mod captions;
|
||||
pub mod clip_ops;
|
||||
|
|
@ -852,6 +853,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod aaf_omf_tests;
|
||||
#[cfg(test)]
|
||||
mod audio_effects_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let r = match &err {
|
||||
// stopping keeps what was tracked
|
||||
Some(e) if e != "stopped" => Err(e.clone()),
|
||||
_ => Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }),
|
||||
_ => Ok(filmcraft_export::Report {
|
||||
path: String::new(),
|
||||
frames: done,
|
||||
seconds: secs,
|
||||
bytes: 0,
|
||||
render_fps: done as f64 / secs.max(1e-6),
|
||||
extra_files: Vec::new(),
|
||||
}),
|
||||
};
|
||||
*lock(&prog.status) = match &err {
|
||||
Some(e) => format!("{e} ({done} frame(s) tracked)"),
|
||||
|
|
|
|||
|
|
@ -141,7 +141,8 @@ fn events_report_background_jobs() {
|
|||
s.jobs.extend([a.clone(), b.clone(), c.clone()]);
|
||||
s.poll_persistence();
|
||||
assert_eq!(s.log.entries.iter().filter(|e| e.message.starts_with("Started:")).count(), 3);
|
||||
*a.result.lock().unwrap() = Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0 }));
|
||||
*a.result.lock().unwrap() =
|
||||
Some(Ok(filmcraft_export::Report { path: "a.mp4".into(), frames: 1, seconds: 0.1, bytes: 1, render_fps: 10.0, extra_files: Vec::new() }));
|
||||
*b.result.lock().unwrap() = Some(Err("disk full".into()));
|
||||
c.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
*c.result.lock().unwrap() = Some(Err("cancelled".into()));
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
|||
seconds: secs,
|
||||
bytes,
|
||||
render_fps: done_frames as f64 / secs.max(1e-6),
|
||||
extra_files: Vec::new(),
|
||||
});
|
||||
if let Err(e) = &r {
|
||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||
|
|
@ -612,7 +613,14 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
store.add_audio(&g.hash);
|
||||
}
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
let r = outcome.map(|_| filmcraft_export::Report { path: dir.to_string_lossy().to_string(), frames: 0, seconds: secs, bytes, render_fps: 0.0 });
|
||||
let r = outcome.map(|_| filmcraft_export::Report {
|
||||
path: dir.to_string_lossy().to_string(),
|
||||
frames: 0,
|
||||
seconds: secs,
|
||||
bytes,
|
||||
render_fps: 0.0,
|
||||
extra_files: Vec::new(),
|
||||
});
|
||||
if let Err(e) = &r {
|
||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
seconds: secs,
|
||||
bytes,
|
||||
render_fps: 0.0,
|
||||
extra_files: Vec::new(),
|
||||
});
|
||||
if let Err(e) = &r {
|
||||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st
|
|||
*prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6) }),
|
||||
None => Ok(Report { path: String::new(), frames, seconds: secs, bytes, render_fps: frames as f64 / secs.max(1e-6), extra_files: Vec::new() }),
|
||||
};
|
||||
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = match &r {
|
||||
Ok(_) => format!("Done in {secs:.1}s"),
|
||||
|
|
|
|||
|
|
@ -193,7 +193,14 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(e) => Err(e),
|
||||
None => {
|
||||
*lock(&out) = Some(found);
|
||||
Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) })
|
||||
Ok(filmcraft_export::Report {
|
||||
path: String::new(),
|
||||
frames: done,
|
||||
seconds: secs,
|
||||
bytes: 0,
|
||||
render_fps: done as f64 / secs.max(1e-6),
|
||||
extra_files: Vec::new(),
|
||||
})
|
||||
}
|
||||
};
|
||||
*lock(&res) = Some(r);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
|
|||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-prores = { workspace = true }
|
||||
filmcraft-dnx = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-h264enc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use filmcraft_time::FrameRate;
|
|||
use rayon::prelude::*;
|
||||
|
||||
use crate::audio_out::AudioOut;
|
||||
use crate::mxf_out::{MxfMux, MxfSetup};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
|
||||
use crate::{
|
||||
|
|
@ -59,6 +60,10 @@ pub struct Exporter {
|
|||
first_pass: bool,
|
||||
/// Created after the first batch (encoders may finalise their codec config then).
|
||||
mux: Option<Mp4Writer<Out>>,
|
||||
/// MXF exports write through this instead of `mux`.
|
||||
mxf: Option<MxfMux>,
|
||||
seq: ItemId,
|
||||
project: Arc<Project>,
|
||||
vt: usize,
|
||||
at: Option<usize>,
|
||||
t0: web_time::Instant,
|
||||
|
|
@ -66,7 +71,7 @@ pub struct Exporter {
|
|||
|
||||
/// Whether [`Exporter`] handles a format.
|
||||
pub fn stepped(format: Format) -> bool {
|
||||
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg)
|
||||
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||
}
|
||||
|
||||
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
|
||||
|
|
@ -74,8 +79,8 @@ fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Resu
|
|||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.find_map(|fac| fac(settings.format, w, h, rate, settings))
|
||||
.ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.format.label())))?
|
||||
.find_map(|fac| fac(settings.video_format(), w, h, rate, settings))
|
||||
.ok_or_else(|| ExportError::Unsupported(format!("{} encoder not available yet", settings.video_format().label())))?
|
||||
}
|
||||
|
||||
impl Exporter {
|
||||
|
|
@ -88,7 +93,7 @@ impl Exporter {
|
|||
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
|
||||
let pipe = q.settings.color;
|
||||
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.format, Format::H264 | Format::ProRes | Format::DnxHr);
|
||||
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr);
|
||||
let mut settings = settings.clone();
|
||||
settings.signal = match (hdr_out, pipe.working) {
|
||||
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
||||
|
|
@ -101,7 +106,7 @@ impl Exporter {
|
|||
settings.max_bitrate_kbps = Some(r.max_kbps);
|
||||
settings.keyframe_distance = Some(r.keyint);
|
||||
settings.adaptive_bitrate = None;
|
||||
let two_pass = settings.format == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass;
|
||||
let two_pass = settings.video_format() == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass;
|
||||
settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single };
|
||||
let range = export_range(&project, seq, &settings)?;
|
||||
let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?;
|
||||
|
|
@ -136,6 +141,9 @@ impl Exporter {
|
|||
measured,
|
||||
first_pass: two_pass,
|
||||
mux: None,
|
||||
mxf: None,
|
||||
seq,
|
||||
project: project.clone(),
|
||||
vt: 0,
|
||||
at: None,
|
||||
t0: web_time::Instant::now(),
|
||||
|
|
@ -158,6 +166,12 @@ impl Exporter {
|
|||
}
|
||||
|
||||
fn write_audio(&mut self, planar: Option<Vec<Vec<f32>>>) -> Result<()> {
|
||||
if let Some(m) = self.mxf.as_mut() {
|
||||
return match planar {
|
||||
Some(buf) => m.write_audio(&buf),
|
||||
None => Ok(()),
|
||||
};
|
||||
}
|
||||
let (Some(at), Some(buf)) = (self.at, planar) else { return Ok(()) };
|
||||
let n = buf.first().map_or(0, Vec::len);
|
||||
if n == 0 {
|
||||
|
|
@ -192,6 +206,9 @@ impl Exporter {
|
|||
}
|
||||
|
||||
fn write_video(&mut self, packets: Vec<EncodedPacket>) -> Result<()> {
|
||||
if let Some(m) = self.mxf.as_mut() {
|
||||
return m.write_video(packets);
|
||||
}
|
||||
let mux = self.mux.as_mut().expect("mux created");
|
||||
for p in packets {
|
||||
mux.write_sample(self.vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key })
|
||||
|
|
@ -202,6 +219,23 @@ impl Exporter {
|
|||
|
||||
/// Create the muxer and its tracks (after the first batch was encoded).
|
||||
fn open_mux(&mut self) -> Result<()> {
|
||||
if self.settings.format.is_mxf() {
|
||||
let q = self.project.sequence(self.seq).ok_or(ExportError::NoSequence)?;
|
||||
// start timecode of the first exported frame, at the output rate
|
||||
let seq_rate = q.settings.frame_rate;
|
||||
let start = self.pipe.rate.frame_at(seq_rate.tick_of(q.start_timecode)) + self.f0;
|
||||
let name = self.project.item(self.seq).map(|i| i.name.clone()).unwrap_or_default();
|
||||
self.mxf = Some(MxfMux::new(MxfSetup {
|
||||
settings: &self.settings,
|
||||
name,
|
||||
width: self.pipe.w,
|
||||
height: self.pipe.h,
|
||||
rate: self.pipe.rate,
|
||||
audio: self.audio.as_ref().map(|a| (a.sr, a.channels)),
|
||||
timecode: (start, q.settings.drop_frame),
|
||||
})?);
|
||||
return Ok(());
|
||||
}
|
||||
let file = Out::create(&self.settings)?;
|
||||
let mut opts = WriterOptions::new(self.brand);
|
||||
opts.metadata = self.settings.metadata.udta();
|
||||
|
|
@ -262,7 +296,7 @@ impl Exporter {
|
|||
return Ok(Step::Progress);
|
||||
}
|
||||
let mixed = match self.audio.as_ref().map(|a| a.sr) {
|
||||
Some(sr) if self.mux.is_none() || self.at.is_some() => {
|
||||
Some(sr) if (self.mux.is_none() && self.mxf.is_none()) || self.at.is_some() || self.mxf.is_some() => {
|
||||
let until = self.sample_at_frame(end, sr);
|
||||
self.audio.as_mut().and_then(|a| a.pull(until, sources))
|
||||
}
|
||||
|
|
@ -272,7 +306,7 @@ impl Exporter {
|
|||
return Ok(Step::Pending);
|
||||
}
|
||||
let packets = self.encode(&frames, f)?;
|
||||
if self.mux.is_none() {
|
||||
if self.mux.is_none() && self.mxf.is_none() {
|
||||
self.open_mux()?;
|
||||
}
|
||||
self.write_video(packets)?;
|
||||
|
|
@ -295,7 +329,7 @@ impl Exporter {
|
|||
if filmcraft_media::pending::take() {
|
||||
return Ok(Step::Pending);
|
||||
}
|
||||
if self.mux.is_none() {
|
||||
if self.mux.is_none() && self.mxf.is_none() {
|
||||
self.open_mux()?;
|
||||
}
|
||||
let tail = self.venc.flush()?;
|
||||
|
|
@ -309,15 +343,27 @@ impl Exporter {
|
|||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
let bytes = w.finish(&self.settings)?;
|
||||
let (bytes, extra_files) = match self.mxf.take() {
|
||||
Some(m) => m.finish(&self.settings)?,
|
||||
None => {
|
||||
let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
(w.finish(&self.settings)?, Vec::new())
|
||||
}
|
||||
};
|
||||
let secs = self.t0.elapsed().as_secs_f64();
|
||||
let nframes = self.frames();
|
||||
if !self.settings.part_of_batch {
|
||||
progress.finished.store(true, Ordering::Relaxed);
|
||||
progress.set_status(format!("Done in {secs:.1}s"));
|
||||
}
|
||||
Ok(Step::Done(Report { path: self.settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) }))
|
||||
Ok(Step::Done(Report {
|
||||
path: self.settings.path.clone(),
|
||||
frames: nframes,
|
||||
seconds: secs,
|
||||
bytes,
|
||||
render_fps: nframes as f64 / secs.max(1e-6),
|
||||
extra_files,
|
||||
}))
|
||||
}
|
||||
|
||||
fn encode(&mut self, frames: &[(Vec<u8>, Vec<f32>)], f: i64) -> Result<Vec<EncodedPacket>> {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ use std::io::Write;
|
|||
|
||||
mod audio_out;
|
||||
mod job;
|
||||
mod mxf_out;
|
||||
mod pcm;
|
||||
mod pipeline;
|
||||
pub mod presets;
|
||||
pub mod settings;
|
||||
pub use audio_out::LoudnessReport;
|
||||
pub use job::{Exporter, Step, stepped};
|
||||
pub use mxf_out::opatom_audio_paths;
|
||||
pub use pcm::{image_sequence_path, write_aiff, write_wav};
|
||||
pub use pipeline::limit_rgba8;
|
||||
pub use presets::{ExportPreset, builtin_presets};
|
||||
|
|
@ -82,6 +84,13 @@ pub enum Format {
|
|||
Wav,
|
||||
#[serde(rename = "aiff", alias = "Aiff")]
|
||||
Aiff,
|
||||
/// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
|
||||
#[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
|
||||
MxfOp1a,
|
||||
/// MXF OP-Atom (SMPTE ST 390, Avid style): the picture in one file, one mono PCM file per
|
||||
/// audio channel ([`opatom_audio_paths`]).
|
||||
#[serde(rename = "mxf-opatom", alias = "MxfOpAtom")]
|
||||
MxfOpAtom,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
|
|
@ -89,7 +98,9 @@ impl Format {
|
|||
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
|
||||
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
||||
"prores" | "mov" | "appleprores" => Format::ProRes,
|
||||
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" | "mxf" => Format::DnxHr,
|
||||
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
|
||||
"mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
|
||||
"mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
|
||||
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
|
||||
"png" | "pngsequence" => Format::PngSequence,
|
||||
"tif" | "tiff" | "tiffsequence" => Format::TiffSequence,
|
||||
|
|
@ -113,6 +124,8 @@ impl Format {
|
|||
Format::Gif => "gif",
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aiff",
|
||||
Format::MxfOp1a => "mxf-op1a",
|
||||
Format::MxfOpAtom => "mxf-opatom",
|
||||
}
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
|
|
@ -125,6 +138,7 @@ impl Format {
|
|||
Format::Gif => "gif",
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aif",
|
||||
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
|
|
@ -139,9 +153,15 @@ impl Format {
|
|||
Format::Gif => "Animated GIF",
|
||||
Format::Wav => "Waveform Audio",
|
||||
Format::Aiff => "AIFF",
|
||||
Format::MxfOp1a => "MXF OP1a",
|
||||
Format::MxfOpAtom => "MXF OP-Atom",
|
||||
}
|
||||
}
|
||||
pub const ALL: [Format; 10] = [
|
||||
/// An MXF container format.
|
||||
pub fn is_mxf(self) -> bool {
|
||||
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
|
||||
}
|
||||
pub const ALL: [Format; 12] = [
|
||||
Format::H264,
|
||||
Format::ProRes,
|
||||
Format::DnxHr,
|
||||
|
|
@ -152,6 +172,8 @@ impl Format {
|
|||
Format::Gif,
|
||||
Format::Wav,
|
||||
Format::Aiff,
|
||||
Format::MxfOp1a,
|
||||
Format::MxfOpAtom,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +214,9 @@ pub struct ExportSettings {
|
|||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||
#[serde(default)]
|
||||
pub dnx_profile: String,
|
||||
/// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply).
|
||||
#[serde(default)]
|
||||
pub mxf_video_codec: MxfVideoCodec,
|
||||
/// Encode display-referred SDR (Rec. 709, tone mapped) even when the sequence works in
|
||||
/// Rec. 2100 PQ/HLG. Otherwise H.264 and ProRes exports of an HDR sequence are encoded in the
|
||||
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||
|
|
@ -259,21 +284,30 @@ enum Out {
|
|||
|
||||
impl Out {
|
||||
fn create(settings: &ExportSettings) -> Result<Out> {
|
||||
Out::create_path(settings, &settings.path)
|
||||
}
|
||||
|
||||
/// An output file at `path` (OP-Atom exports write several).
|
||||
fn create_path(settings: &ExportSettings, path: &str) -> Result<Out> {
|
||||
if settings.sink.is_some() {
|
||||
return Ok(Out::Mem(std::io::Cursor::new(Vec::new())));
|
||||
}
|
||||
let f = std::fs::File::create(&settings.path).map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?;
|
||||
Ok(Out::File(std::io::BufWriter::new(f)))
|
||||
}
|
||||
|
||||
/// Flush (and hand in-memory output to the sink); returns the file size.
|
||||
fn finish(self, settings: &ExportSettings) -> Result<u64> {
|
||||
self.finish_path(settings, &settings.path)
|
||||
}
|
||||
|
||||
fn finish_path(self, settings: &ExportSettings, path: &str) -> Result<u64> {
|
||||
match self {
|
||||
Out::File(mut w) => {
|
||||
w.flush().map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
Ok(std::fs::metadata(&settings.path).map(|m| m.len()).unwrap_or(0))
|
||||
Ok(std::fs::metadata(path).map(|m| m.len()).unwrap_or(0))
|
||||
}
|
||||
Out::Mem(c) => write_output(settings, &settings.path, c.into_inner()),
|
||||
Out::Mem(c) => write_output(settings, path, c.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -372,6 +406,7 @@ impl Default for ExportSettings {
|
|||
part_of_batch: false,
|
||||
prores_profile: String::new(),
|
||||
dnx_profile: String::new(),
|
||||
mxf_video_codec: MxfVideoCodec::default(),
|
||||
sdr: false,
|
||||
frame_size: None,
|
||||
frame_rate: None,
|
||||
|
|
@ -441,6 +476,9 @@ pub struct Report {
|
|||
pub seconds: f64,
|
||||
pub bytes: u64,
|
||||
pub render_fps: f64,
|
||||
/// Further files written besides `path` (MXF OP-Atom audio files).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// A packet produced by a video encoder.
|
||||
|
|
@ -883,8 +921,9 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
|
|||
return None;
|
||||
}
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
// MXF carries the Annex B byte stream (ST 381-3) with in-band parameter sets
|
||||
cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed };
|
||||
cfg.aud = s.format.is_mxf();
|
||||
cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32);
|
||||
let kbps = s.bitrate_kbps.max(100);
|
||||
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
|
||||
|
|
@ -1069,14 +1108,14 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
|||
}
|
||||
(total, count)
|
||||
}
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg => unreachable!("stepped export"),
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => unreachable!("stepped export"),
|
||||
};
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
if !settings.part_of_batch {
|
||||
progress.finished.store(true, Ordering::Relaxed);
|
||||
progress.set_status(format!("Done in {secs:.1}s"));
|
||||
}
|
||||
Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6) })
|
||||
Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6), extra_files: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1087,3 +1126,6 @@ mod settings_tests;
|
|||
|
||||
#[cfg(test)]
|
||||
mod surround_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mxf_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
//! MXF output of the stepped exporter: OP1a (one file, frame-wrapped picture + one PCM track) or
|
||||
//! OP-Atom (Avid style: the picture in `path`, one mono PCM file per audio channel next to it,
|
||||
//! `<stem>_A1.mxf`, `<stem>_A2.mxf`, …), written with [`filmcraft_mxf::MxfWriter`].
|
||||
|
||||
use filmcraft_mxf::{
|
||||
CodedKind, ColorSpace, FrameInfo, MxfWriter, PackageIds, Pattern, PictureCoding, PictureDesc, Rational, SoundDesc, StartTimecode, Timestamp, WriterConfig,
|
||||
};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
use crate::{ColorSignal, EncodedPacket, ExportError, ExportSettings, Format, H264Profile, Out, Result};
|
||||
|
||||
/// Paths of the OP-Atom audio files for an export to `path` with `channels` channels.
|
||||
pub fn opatom_audio_paths(path: &str, channels: usize) -> Vec<String> {
|
||||
let p = std::path::Path::new(path);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "export".into());
|
||||
(1..=channels)
|
||||
.map(|k| {
|
||||
let name = format!("{stem}_A{k}.mxf");
|
||||
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
Some(d) => d.join(name).to_string_lossy().to_string(),
|
||||
None => name,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> ExportError {
|
||||
ExportError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// The picture essence description for an export (codec from the MXF video codec setting).
|
||||
fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
|
||||
let coding = match settings.video_format() {
|
||||
Format::ProRes => {
|
||||
use filmcraft_prores::Profile;
|
||||
PictureCoding::ProRes {
|
||||
profile: match crate::prores_profile(&settings.prores_profile) {
|
||||
Profile::Proxy => 1,
|
||||
Profile::Lt => 2,
|
||||
Profile::Standard => 3,
|
||||
_ => 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
Format::H264 => PictureCoding::Avc {
|
||||
profile_idc: match settings.h264_profile {
|
||||
H264Profile::Baseline => 66,
|
||||
H264Profile::Main => 77,
|
||||
H264Profile::High => 100,
|
||||
},
|
||||
intra: false,
|
||||
},
|
||||
_ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() },
|
||||
};
|
||||
let mut d = PictureDesc::new(coding, w, h);
|
||||
if matches!(coding, PictureCoding::ProRes { .. }) {
|
||||
d.depth = 10;
|
||||
}
|
||||
d.color = match settings.signal {
|
||||
s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq,
|
||||
s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg,
|
||||
_ => ColorSpace::Rec709,
|
||||
};
|
||||
if let Some((n, dn)) = settings.pixel_aspect.filter(|(n, d)| *n > 0 && *d > 0 && n != d) {
|
||||
let (a, b) = (w as u64 * n as u64, h as u64 * dn as u64);
|
||||
let g = gcd(a, b);
|
||||
d.aspect = Rational::new((a / g) as i32, (b / g) as i32);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn gcd(a: u64, b: u64) -> u64 {
|
||||
if b == 0 { a.max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
/// Everything the MXF muxer needs from the export.
|
||||
pub(crate) struct MxfSetup<'a> {
|
||||
pub settings: &'a ExportSettings,
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rate: FrameRate,
|
||||
/// (sample rate, channels) of the audio, if any.
|
||||
pub audio: Option<(u32, usize)>,
|
||||
/// Start timecode in frames of `rate`, and drop-frame.
|
||||
pub timecode: (i64, bool),
|
||||
}
|
||||
|
||||
/// An MXF export in progress.
|
||||
pub(crate) struct MxfMux {
|
||||
video: MxfWriter<Out>,
|
||||
/// OP-Atom: one writer per audio channel.
|
||||
audio: Vec<(String, MxfWriter<Out>)>,
|
||||
atom: bool,
|
||||
bits: u32,
|
||||
channels: usize,
|
||||
/// Stored pictures so far, and the composition offset of the first (H.264 reorder delay).
|
||||
stored: i64,
|
||||
first_offset: Option<i64>,
|
||||
max_display: i64,
|
||||
frame_den: i64,
|
||||
}
|
||||
|
||||
impl MxfMux {
|
||||
pub fn new(s: MxfSetup) -> Result<Self> {
|
||||
let settings = s.settings;
|
||||
let atom = settings.format == Format::MxfOpAtom;
|
||||
let rate = Rational::new(s.rate.num as i32, s.rate.den as i32);
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
let seed = format!("{}|{now}", settings.path);
|
||||
let ids = PackageIds::from_seed(&seed, &s.name);
|
||||
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
|
||||
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
|
||||
cfg.picture = Some(picture(settings, s.width, s.height));
|
||||
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
|
||||
cfg.timecode = Some(StartTimecode { frames: s.timecode.0, rate, drop_frame: s.timecode.1 && s.rate.supports_drop_frame() });
|
||||
if !atom && let Some((sr, ch)) = s.audio {
|
||||
cfg.sound = vec![SoundDesc { sample_rate: sr, channels: ch as u32, bits }];
|
||||
}
|
||||
let mut audio = Vec::new();
|
||||
if atom && let Some((sr, ch)) = s.audio {
|
||||
for (k, path) in opatom_audio_paths(&settings.path, ch).into_iter().enumerate() {
|
||||
// Avid style: the files share the material package; each has its own file package.
|
||||
let mut a = WriterConfig::new(
|
||||
Pattern::OpAtom,
|
||||
rate,
|
||||
PackageIds {
|
||||
material: ids.material,
|
||||
file: filmcraft_mxf::Umid::from_seed(format!("file:{seed}:A{}", k + 1).as_bytes()),
|
||||
material_name: ids.material_name.clone(),
|
||||
file_name: format!("{} A{}", s.name, k + 1),
|
||||
},
|
||||
);
|
||||
a.first_track_id = 3 + k as u32;
|
||||
a.sound = vec![SoundDesc { sample_rate: sr, channels: 1, bits }];
|
||||
a.timecode = cfg.timecode;
|
||||
a.modified = cfg.modified;
|
||||
let out = Out::create_path(settings, &path)?;
|
||||
audio.push((path, MxfWriter::new(out, a).map_err(io)?));
|
||||
}
|
||||
}
|
||||
let video = MxfWriter::new(Out::create_path(settings, &settings.path)?, cfg).map_err(io)?;
|
||||
Ok(MxfMux {
|
||||
video,
|
||||
audio,
|
||||
atom,
|
||||
bits,
|
||||
channels: s.audio.map_or(0, |a| a.1),
|
||||
stored: 0,
|
||||
first_offset: None,
|
||||
max_display: -1,
|
||||
frame_den: s.rate.den.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_video(&mut self, packets: Vec<EncodedPacket>) -> Result<()> {
|
||||
for p in packets {
|
||||
let intra = !matches!(self.video.config().picture.as_ref().map(|p| p.coding), Some(PictureCoding::Avc { .. }));
|
||||
let info = if intra {
|
||||
FrameInfo::intra()
|
||||
} else {
|
||||
// display position from the composition offset (pts − dts) relative to the first
|
||||
let off = p.composition_offset as i64;
|
||||
let first = *self.first_offset.get_or_insert(off);
|
||||
let display = self.stored + (off - first) / self.frame_den;
|
||||
let kind = if p.key {
|
||||
CodedKind::Intra
|
||||
} else if display < self.max_display {
|
||||
CodedKind::Bidirectional
|
||||
} else {
|
||||
CodedKind::Predicted
|
||||
};
|
||||
self.max_display = self.max_display.max(display);
|
||||
FrameInfo { key: p.key, kind, display: Some(display) }
|
||||
};
|
||||
self.video.push_picture(&p.data, info).map_err(io)?;
|
||||
self.stored += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_audio(&mut self, planar: &[Vec<f32>]) -> Result<()> {
|
||||
if self.channels == 0 || planar.first().is_none_or(Vec::is_empty) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.atom {
|
||||
for (k, (_, w)) in self.audio.iter_mut().enumerate() {
|
||||
if let Some(c) = planar.get(k) {
|
||||
w.push_sound(0, &filmcraft_mxf::encode_pcm(std::slice::from_ref(c), self.bits)).map_err(io)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
self.video.push_sound(0, &filmcraft_mxf::encode_pcm(planar, self.bits)).map_err(io)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish every file; returns (total bytes, extra files written besides `settings.path`).
|
||||
pub fn finish(self, settings: &ExportSettings) -> Result<(u64, Vec<String>)> {
|
||||
let mut total = self.video.finish().map_err(io)?.finish_path(settings, &settings.path)?;
|
||||
let mut extra = Vec::new();
|
||||
for (path, w) in self.audio {
|
||||
total += w.finish().map_err(io)?.finish_path(settings, &path)?;
|
||||
extra.push(path);
|
||||
}
|
||||
Ok((total, extra))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
//! MXF export (OP1a / OP-Atom): read back with our demuxer and decoders, and against ffprobe /
|
||||
//! ffmpeg as external oracles (decode must succeed and match the same essence exported as MOV;
|
||||
//! PCM sample-exact).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
use filmcraft_media::generators::GeneratorSource;
|
||||
use filmcraft_media::{FrameRequest, Generator, MediaSource};
|
||||
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind};
|
||||
use filmcraft_render::SourceMap;
|
||||
use filmcraft_time::TICKS_PER_SECOND;
|
||||
|
||||
/// 1 s at 24 fps, 320×180: the counting leader over V1, a 440 Hz tone on A1 (stereo mix), start
|
||||
/// timecode 01:00:00:00.
|
||||
fn project() -> (Arc<Project>, ItemId, SourceMap) {
|
||||
let mut p = Project::new("mxf");
|
||||
let r = FrameRate::FPS_24;
|
||||
let leader = GeneratorSource::new(Generator::CountingLeader, 320, 180, r, Tick(2 * TICKS_PER_SECOND));
|
||||
let tone = GeneratorSource::new(Generator::Tone { hz: 440.0, db: -6.0 }, 320, 180, r, Tick(2 * TICKS_PER_SECOND));
|
||||
let add = |p: &mut Project, g: &GeneratorSource| {
|
||||
let info = g.info().clone();
|
||||
p.add_item(
|
||||
&info.name.clone(),
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::Generator(g.generator.clone()),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let v = add(&mut p, &leader);
|
||||
let a = add(&mut p, &tone);
|
||||
let seq = p.new_sequence("MXF Test", SequenceSettings { width: 320, height: 180, frame_rate: r, ..Default::default() }, 1, 1, None);
|
||||
let vi = p.make_track_item(v, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap();
|
||||
let ai = p.make_track_item(a, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
q.video_tracks[0].items.push(vi);
|
||||
q.audio_tracks[0].items.push(ai);
|
||||
q.start_timecode = 24 * 3600;
|
||||
let mut m = SourceMap::default();
|
||||
m.0.insert(v, Arc::new(leader));
|
||||
m.0.insert(a, Arc::new(tone));
|
||||
(Arc::new(p), seq, m)
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("fc-export-mxf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn settings(format: Format, codec: MxfVideoCodec, path: &str) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format,
|
||||
path: path.into(),
|
||||
mxf_video_codec: codec,
|
||||
audio: AudioSettings { bits: 24, sample_rate: Some(48_000), channels: 2, ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ffprobe_json(ffprobe: &Path, path: &str) -> serde_json::Value {
|
||||
let out = Command::new(ffprobe).args(["-v", "error", "-show_format", "-show_streams", "-count_frames", "-of", "json", path]).output().unwrap();
|
||||
assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
serde_json::from_slice(&out.stdout).unwrap()
|
||||
}
|
||||
|
||||
/// ffmpeg's decode of `path` (stream `map`) as raw bytes in `fmt`.
|
||||
fn ffmpeg_raw(ffmpeg: &Path, path: &str, map: &str, args: &[&str]) -> Vec<u8> {
|
||||
let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-map", map]).args(args).arg("-").output().unwrap();
|
||||
assert!(out.status.success(), "ffmpeg {path}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout
|
||||
}
|
||||
|
||||
fn codec_name(c: MxfVideoCodec) -> &'static str {
|
||||
match c {
|
||||
MxfVideoCodec::Dnxhr => "dnxhd",
|
||||
MxfVideoCodec::ProRes => "prores",
|
||||
MxfVideoCodec::H264 => "h264",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op1a_reads_back_with_our_demuxer_and_decoders() {
|
||||
let (p, seq, m) = project();
|
||||
for codec in MxfVideoCodec::ALL {
|
||||
let path = tmp(&format!("op1a_{codec:?}.mxf"));
|
||||
let r = export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap();
|
||||
assert_eq!(r.frames, 24);
|
||||
assert!(r.extra_files.is_empty());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let mx = filmcraft_mxf::open(&bytes).unwrap();
|
||||
assert!(mx.warnings.is_empty(), "{codec:?}: {:?}", mx.warnings);
|
||||
assert_eq!(mx.operational_pattern.name(), "OP1a");
|
||||
assert_eq!(mx.material_package_name.as_deref(), Some("MXF Test"));
|
||||
assert_eq!(mx.timecode.unwrap().format(), "01:00:00:00");
|
||||
let v = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Picture).unwrap()];
|
||||
assert_eq!(v.samples.len(), 24, "{codec:?}");
|
||||
assert_eq!(v.duration, Some(24));
|
||||
let a = &mx.tracks[mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap()];
|
||||
assert_eq!(a.stored_sample_frames(), 48_000, "{codec:?}");
|
||||
assert_eq!(a.sound.as_ref().unwrap().bits, 24);
|
||||
if codec == MxfVideoCodec::H264 {
|
||||
assert!(v.temporal_offsets, "B pictures: the index gives the presentation order");
|
||||
assert!(v.samples[0].key && v.samples.iter().filter(|s| s.key).count() < 24);
|
||||
}
|
||||
// our MXF source decodes it like the MOV export of the same sequence
|
||||
let src = filmcraft_codecs::open_bytes("x.mxf", bytes.into()).unwrap();
|
||||
let info = src.info();
|
||||
assert_eq!(info.video.as_ref().unwrap().width, 320, "{codec:?}");
|
||||
assert!((info.duration.seconds() - 1.0).abs() < 0.05, "{codec:?}: {}", info.duration.seconds());
|
||||
let mov = tmp(&format!("op1a_{codec:?}.mov"));
|
||||
let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) };
|
||||
export(&p, seq, &ms, &m, &Progress::default()).unwrap();
|
||||
let msrc = filmcraft_codecs::open_bytes("x.mov", std::fs::read(&mov).unwrap().into()).unwrap();
|
||||
for f in [0i64, 5, 13, 23, 7] {
|
||||
let t = FrameRate::FPS_24.tick_of(f) + Tick(1);
|
||||
let a = src.video_frame(FrameRequest::full(t)).unwrap().to_rgba8();
|
||||
let b = msrc.video_frame(FrameRequest::full(t)).unwrap().to_rgba8();
|
||||
assert!(a == b, "{codec:?}: frame {f} differs from the MOV export");
|
||||
}
|
||||
let pcm = src.audio(0, 48_000, 48_000).unwrap();
|
||||
assert!((pcm.peaks()[0] - 0.5).abs() < 0.05, "{codec:?}: tone at -6 dB: {}", pcm.peaks()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_atom_writes_one_file_per_essence() {
|
||||
let (p, seq, m) = project();
|
||||
let path = tmp("atom.mxf");
|
||||
let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap();
|
||||
assert_eq!(r.extra_files, opatom_audio_paths(&path, 2));
|
||||
assert!(r.extra_files[0].ends_with("atom_A1.mxf") && r.extra_files[1].ends_with("atom_A2.mxf"));
|
||||
let v = std::fs::read(&path).unwrap();
|
||||
let mv = filmcraft_mxf::open(&v).unwrap();
|
||||
assert_eq!(mv.operational_pattern.name(), "OP-Atom");
|
||||
assert_eq!(mv.tracks.len(), 1);
|
||||
assert_eq!(mv.tracks[0].wrapping, filmcraft_mxf::Wrapping::Clip);
|
||||
assert_eq!(mv.tracks[0].samples.len(), 24);
|
||||
for (k, a) in r.extra_files.iter().enumerate() {
|
||||
let b = std::fs::read(a).unwrap();
|
||||
let ma = filmcraft_mxf::open(&b).unwrap();
|
||||
assert_eq!(ma.operational_pattern.name(), "OP-Atom");
|
||||
let t = &ma.tracks[0];
|
||||
assert_eq!(t.kind, filmcraft_mxf::TrackKind::Sound);
|
||||
assert_eq!(t.track_id, 3 + k as u32);
|
||||
assert_eq!(t.sound.as_ref().unwrap().channels, 1);
|
||||
assert_eq!(t.stored_sample_frames(), 48_000);
|
||||
assert_eq!(t.duration, Some(24), "audio edit rate is the frame rate");
|
||||
assert_eq!(ma.timecode.unwrap().format(), "01:00:00:00");
|
||||
let pcm = ma.read_pcm(&b, 0, 0, 48_000).unwrap();
|
||||
assert!((pcm[0].iter().fold(0f32, |a, s| a.max(s.abs())) - 0.5).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mxf_formats_and_presets() {
|
||||
assert_eq!(Format::from_name("mxf"), Some(Format::MxfOp1a));
|
||||
assert_eq!(Format::from_name("mxf-op1a"), Some(Format::MxfOp1a));
|
||||
assert_eq!(Format::from_name("mxf-opatom"), Some(Format::MxfOpAtom));
|
||||
assert_eq!(Format::from_name(Format::MxfOpAtom.id()), Some(Format::MxfOpAtom));
|
||||
assert_eq!(Format::MxfOp1a.extension(), "mxf");
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "mxf-op1a", "mxfVideoCodec": "proRes"})).unwrap();
|
||||
assert_eq!((s.format, s.video_format()), (Format::MxfOp1a, Format::ProRes));
|
||||
assert_eq!(s.audio_codec(), AudioCodec::Pcm);
|
||||
assert!(s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).format.contains("MXF OP1a (Apple ProRes)"));
|
||||
let names: Vec<String> = builtin_presets().into_iter().filter(|p| p.settings.format.is_mxf()).map(|p| p.name).collect();
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
assert_eq!(opatom_audio_paths("/a/b/clip.mxf", 2), vec!["/a/b/clip_A1.mxf".to_string(), "/a/b/clip_A2.mxf".to_string()]);
|
||||
}
|
||||
|
||||
/// ffprobe identifies our files (operational pattern, codec, frame count, duration, timecode);
|
||||
/// ffmpeg decodes them to exactly what it decodes from the same essence in a MOV export, and
|
||||
/// the PCM is sample-exact against what we wrote.
|
||||
#[test]
|
||||
fn ffmpeg_oracle_op1a_and_op_atom() {
|
||||
let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("MXF export probe"), filmcraft_testkit::ffmpeg_or_skip("MXF export decode")) else {
|
||||
return;
|
||||
};
|
||||
let (p, seq, m) = project();
|
||||
for codec in MxfVideoCodec::ALL {
|
||||
let path = tmp(&format!("oracle_{codec:?}.mxf"));
|
||||
export(&p, seq, &settings(Format::MxfOp1a, codec, &path), &m, &Progress::default()).unwrap();
|
||||
let j = ffprobe_json(&ffprobe, &path);
|
||||
assert_eq!(j["format"]["format_name"], "mxf", "{codec:?}");
|
||||
let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default();
|
||||
assert!(op.starts_with("060e2b34.04010101.0d010201.0101"), "{codec:?}: OP1a label {op}");
|
||||
let streams = j["streams"].as_array().unwrap();
|
||||
let v = streams.iter().find(|s| s["codec_type"] == "video").unwrap();
|
||||
let a = streams.iter().find(|s| s["codec_type"] == "audio").unwrap();
|
||||
assert_eq!(v["codec_name"], codec_name(codec), "{codec:?}");
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(320), Some(180)));
|
||||
assert_eq!(v["nb_read_frames"], "24", "{codec:?}");
|
||||
assert_eq!(v["r_frame_rate"], "24/1", "{codec:?}");
|
||||
let tc = j["format"]["tags"]["timecode"].as_str().or(v["tags"]["timecode"].as_str()).unwrap_or_default();
|
||||
assert_eq!(tc, "01:00:00:00", "{codec:?}");
|
||||
let dur: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap();
|
||||
assert!((dur - 1.0).abs() < 0.05, "{codec:?}: {dur}");
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{codec:?}");
|
||||
assert_eq!(a["channels"], 2);
|
||||
assert_eq!(a["sample_rate"], "48000");
|
||||
// ffmpeg's decode of the MXF equals its decode of the same essence in MOV
|
||||
let mov = tmp(&format!("oracle_{codec:?}.mov"));
|
||||
let ms = ExportSettings { format: codec.encoder_format(), multiplexer: Multiplexer::Mov, ..settings(Format::MxfOp1a, codec, &mov) };
|
||||
export(&p, seq, &ms, &m, &Progress::default()).unwrap();
|
||||
let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"];
|
||||
let a_mxf = ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw);
|
||||
let a_mov = ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw);
|
||||
assert_eq!(a_mxf.len(), 24 * 320 * 180 * 4, "{codec:?}: 24 frames decoded");
|
||||
assert!(a_mxf == a_mov, "{codec:?}: ffmpeg decodes the MXF and the MOV essence differently");
|
||||
// PCM exact: ffmpeg's samples are what our demuxer reads back
|
||||
let pcm = ffmpeg_raw(&ffmpeg, &path, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let mx = filmcraft_mxf::open(&bytes).unwrap();
|
||||
let at = mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
|
||||
let ours = mx.read_pcm(&bytes, at, 0, 48_000).unwrap();
|
||||
assert_eq!(pcm.len(), 48_000 * 2 * 4, "{codec:?}");
|
||||
for (i, c) in pcm.chunks_exact(4).enumerate() {
|
||||
let s = i32::from_le_bytes([c[0], c[1], c[2], c[3]]) >> 8;
|
||||
assert_eq!(s as f32 / 8_388_608.0, ours[i % 2][i / 2], "{codec:?}: sample {i}");
|
||||
}
|
||||
}
|
||||
// OP-Atom: picture file and one mono PCM file per channel
|
||||
let path = tmp("oracle_atom.mxf");
|
||||
let r = export(&p, seq, &settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &path), &m, &Progress::default()).unwrap();
|
||||
let j = ffprobe_json(&ffprobe, &path);
|
||||
let op = j["format"]["tags"]["operational_pattern_ul"].as_str().unwrap_or_default();
|
||||
assert!(op.starts_with("060e2b34.04010102.0d010201.10"), "OP-Atom label {op}");
|
||||
let v = &j["streams"][0];
|
||||
assert_eq!(v["codec_name"], "dnxhd");
|
||||
assert_eq!(v["nb_read_frames"], "24");
|
||||
assert_eq!(j["streams"].as_array().unwrap().len(), 1);
|
||||
let mov = tmp("oracle_atom.mov");
|
||||
let ms = ExportSettings { format: Format::DnxHr, multiplexer: Multiplexer::Mov, ..settings(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, &mov) };
|
||||
export(&p, seq, &ms, &m, &Progress::default()).unwrap();
|
||||
let raw = ["-f", "rawvideo", "-pix_fmt", "yuv422p10le"];
|
||||
assert!(ffmpeg_raw(&ffmpeg, &path, "0:v:0", &raw) == ffmpeg_raw(&ffmpeg, &mov, "0:v:0", &raw), "OP-Atom picture decode");
|
||||
let wav = tmp("oracle_atom.wav");
|
||||
let ws = ExportSettings { format: Format::Wav, ..settings(Format::Wav, MxfVideoCodec::Dnxhr, &wav) };
|
||||
export(&p, seq, &ws, &m, &Progress::default()).unwrap();
|
||||
let stereo = ffmpeg_raw(&ffmpeg, &wav, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
|
||||
for (k, a) in r.extra_files.iter().enumerate() {
|
||||
let j = ffprobe_json(&ffprobe, a);
|
||||
let s = &j["streams"][0];
|
||||
assert_eq!((s["codec_name"].as_str(), s["channels"].as_u64(), s["sample_rate"].as_str()), (Some("pcm_s24le"), Some(1), Some("48000")), "{a}");
|
||||
let mono = ffmpeg_raw(&ffmpeg, a, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
|
||||
assert_eq!(mono.len(), 48_000 * 4, "{a}");
|
||||
let want: Vec<u8> = stereo.chunks_exact(8).flat_map(|f| f[k * 4..k * 4 + 4].to_vec()).collect();
|
||||
assert!(mono == want, "{a}: channel {k} differs from the WAV export");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
use filmcraft_time::FrameRate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::settings::{AudioCodec, AudioSettings, BitrateMode, Scaling};
|
||||
use crate::settings::{AudioCodec, AudioSettings, BitrateMode, MxfVideoCodec, Scaling};
|
||||
use crate::{ExportSettings, Format};
|
||||
|
||||
/// A named set of export settings (`path` and `range` are left empty).
|
||||
|
|
@ -64,6 +64,20 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
|
|||
s
|
||||
}
|
||||
|
||||
fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings {
|
||||
let mut s = ExportSettings { mxf_video_codec: codec, ..mov(format, profile) };
|
||||
match codec {
|
||||
MxfVideoCodec::ProRes => s.prores_profile = profile.into(),
|
||||
MxfVideoCodec::Dnxhr => s.dnx_profile = profile.into(),
|
||||
MxfVideoCodec::H264 => {
|
||||
s.bitrate_kbps = 50_000;
|
||||
s.adaptive_bitrate = Some(0.3);
|
||||
s.bitrate_mode = BitrateMode::Vbr1Pass;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn audio_only(format: Format, bits: u16) -> ExportSettings {
|
||||
ExportSettings {
|
||||
format,
|
||||
|
|
@ -110,6 +124,15 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
|||
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
|
||||
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
|
||||
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
|
||||
p("MXF OP1a DNxHR HQ", bc, "MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::Dnxhr, "hq")),
|
||||
p("MXF OP1a ProRes 422 HQ", bc, "MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::ProRes, "hq")),
|
||||
p("MXF OP1a H.264", bc, "MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM", mxf(Format::MxfOp1a, MxfVideoCodec::H264, "")),
|
||||
p(
|
||||
"MXF OP-Atom DNxHR (Avid)",
|
||||
bc,
|
||||
"Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…)",
|
||||
mxf(Format::MxfOpAtom, MxfVideoCodec::Dnxhr, "hq"),
|
||||
),
|
||||
p(
|
||||
"PNG Sequence",
|
||||
"Image Sequence",
|
||||
|
|
|
|||
|
|
@ -94,6 +94,38 @@ pub enum Multiplexer {
|
|||
Mov,
|
||||
}
|
||||
|
||||
/// Video codec of an MXF export.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MxfVideoCodec {
|
||||
/// Avid DNxHR (VC-3, SMPTE ST 2019-4 mapping).
|
||||
#[default]
|
||||
Dnxhr,
|
||||
/// Apple ProRes (RDD 44 mapping).
|
||||
ProRes,
|
||||
/// H.264 long GOP (ST 381-3 byte-stream mapping).
|
||||
H264,
|
||||
}
|
||||
|
||||
impl MxfVideoCodec {
|
||||
pub const ALL: [MxfVideoCodec; 3] = [MxfVideoCodec::Dnxhr, MxfVideoCodec::ProRes, MxfVideoCodec::H264];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MxfVideoCodec::Dnxhr => "Avid DNxHR",
|
||||
MxfVideoCodec::ProRes => "Apple ProRes",
|
||||
MxfVideoCodec::H264 => "H.264",
|
||||
}
|
||||
}
|
||||
/// The export format whose encoder this codec uses.
|
||||
pub fn encoder_format(self) -> Format {
|
||||
match self {
|
||||
MxfVideoCodec::Dnxhr => Format::DnxHr,
|
||||
MxfVideoCodec::ProRes => Format::ProRes,
|
||||
MxfVideoCodec::H264 => Format::H264,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -307,6 +339,11 @@ pub struct Resolved {
|
|||
}
|
||||
|
||||
impl ExportSettings {
|
||||
/// The format whose video encoder runs: the MXF video codec for MXF exports, else the format.
|
||||
pub fn video_format(&self) -> Format {
|
||||
if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format }
|
||||
}
|
||||
|
||||
/// Whether the output is a numbered image sequence.
|
||||
pub fn is_image_sequence(&self) -> bool {
|
||||
matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)
|
||||
|
|
@ -354,7 +391,7 @@ impl ExportSettings {
|
|||
/// The audio codec actually used.
|
||||
pub fn audio_codec(&self) -> AudioCodec {
|
||||
match (self.format, self.audio.codec) {
|
||||
(Format::Wav | Format::Aiff, _) => AudioCodec::Pcm,
|
||||
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
|
||||
(Format::H264, _) if self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) => AudioCodec::Pcm,
|
||||
|
|
@ -383,7 +420,7 @@ impl ExportSettings {
|
|||
let secs = duration.seconds().max(0.0);
|
||||
let fps = r.rate.num as f64 / r.rate.den as f64;
|
||||
let px = r.width as f64 * r.height as f64;
|
||||
let video_bps = match self.format {
|
||||
let video_bps = match self.video_format() {
|
||||
Format::H264 => r.target_kbps as f64 * 1000.0,
|
||||
Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
|
||||
Format::DnxHr => {
|
||||
|
|
@ -400,7 +437,7 @@ impl ExportSettings {
|
|||
Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps,
|
||||
Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps,
|
||||
Format::Gif => px * 0.6 * 8.0 * fps,
|
||||
Format::Wav | Format::Aiff => 0.0,
|
||||
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
|
||||
};
|
||||
let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
|
||||
match self.audio_codec() {
|
||||
|
|
@ -434,7 +471,7 @@ impl ExportSettings {
|
|||
} else {
|
||||
let par = self.pixel_aspect.map(|(n, d)| format!("{:.2}", n.max(1) as f64 / d.max(1) as f64)).unwrap_or_else(|| "1.0".into());
|
||||
let mut v = format!("{}x{} ({par}), {} fps, {}", r.width, r.height, r.rate.label(), self.field_order.label());
|
||||
match self.format {
|
||||
match self.video_format() {
|
||||
Format::H264 => {
|
||||
v += &format!(
|
||||
", {} {}, {}",
|
||||
|
|
@ -480,6 +517,7 @@ impl ExportSettings {
|
|||
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
||||
Format::H264 => "MP4",
|
||||
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
|
||||
Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
|
||||
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
||||
_ => "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,56 @@
|
|||
# filmcraft-interchange
|
||||
|
||||
Timeline interchange for FilmCraft: CMX 3600 EDL, Final Cut Pro 7 XML (xmeml), FCPXML,
|
||||
OpenTimelineIO, AAF and OMF import and export, plus Avid ALE. Layer L2: no file I/O; media
|
||||
references are strings and audio essence is supplied by the caller (the engine).
|
||||
|
||||
## Specifications
|
||||
|
||||
Clean-room: written from public specifications only. The AAF SDK, pyaaf2, the OMF Toolkit and
|
||||
other implementations were not consulted.
|
||||
|
||||
| Format | Document | Edition | Used for |
|
||||
|---|---|---|---|
|
||||
| AAF | AMWA *AAF Object Specification* | v1.1 (2005) | classes, properties (ids shared with SMPTE ST 377-1 local tags), mob / slot / segment model, sequences and transitions, operation groups, parameters, definitions, descriptors, locators, essence data |
|
||||
| AAF | AMWA *AAF Low-Level Container Specification* | v1.0.1 | objects as structured storages, `properties` streams, stored forms, strong / weak reference collections and their index streams, `referenced properties` |
|
||||
| AAF | AMWA *AAF Edit Protocol* | v1.0 (AMWA AS-01) | top-level composition, mob chains (composition → master → file → physical source), operation definitions (dissolve, fade to black, audio gain / dissolve), usage codes |
|
||||
| AAF | SMPTE RP 224 / RP 210 registers | — | data definition, class and type labels |
|
||||
| AAF | Microsoft [MS-CFB] | rev. 10.0 | the container, see [`filmcraft-cfb`](../cfb/README.md) |
|
||||
| OMF | Avid *OMF Interchange Specification* | Version 2.0 (1997) | classes (`HEAD`, `CMOB`, `MMOB`, `SMOB`, `MSLT`, `TRKD`, `SEQU`, `SCLP`, `FILL`, `TRAN`, `EFFE`, `ESLT`, `CVAL`, `VVAL`, `TCCP`, `WAVD`, `AIFD`, `WAVE`, `AIFC`, locators), property and type names |
|
||||
| OMF | Apple *Bento Specification* | revision 1.0d5 | container label, TOC encoding, objects / properties / types, references |
|
||||
| WAVE / AIFF | Microsoft RIFF WAVE; Apple AIFF 1.3 | — | embedded and separate audio files |
|
||||
|
||||
The other formats' editions are listed in the module docs (`edl`, `fcp7`, `fcpxml`, `otio`, `ale`).
|
||||
|
||||
## AAF and OMF
|
||||
|
||||
`comp` holds a format-neutral mob-style model (compositions of slots whose sequences of fillers,
|
||||
source clips and overlapping transitions reference media through master and file source mobs).
|
||||
`aaf` and `omf` serialise it; both import back into the same model and from there into FilmCraft
|
||||
sequences and media. Mapping details (what each FilmCraft feature becomes) are in the `aaf` and
|
||||
`omf` module docs. Times are kept in ticks in the model; compositions use the sequence frame rate
|
||||
for picture and the sample rate for sound slots, so audio edits are sample-exact.
|
||||
|
||||
Embedded / consolidated audio (`essence`): `audio_needs` lists the media ranges an export
|
||||
references (per media item or per clip, with handles); the engine decodes or renders them and
|
||||
passes `AudioEssence` (embedded PCM or a written file) back in `MediaOptions`.
|
||||
|
||||
Not represented: speed changes and frame holds (exported at 100 % with a report entry), nested
|
||||
sequences, graphics and synthetic media (gaps), video effects other than transitions, clip
|
||||
markers (written to the master mob, so they come back as media markers), OMF video and markers.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps,
|
||||
two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed
|
||||
gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio
|
||||
(sample data and source offsets); separate consolidated files with a video mixdown; OMF round
|
||||
trips with embedded audio, separate AIFF files and breakout; empty sequences; truncation and
|
||||
random corruption never panic.
|
||||
- `src/aaf/tests.rs`: written files checked against the required properties of every class
|
||||
written (Header, Identification, Mobs, slots, components, descriptors), weak references
|
||||
resolving into the dictionary, source clips resolving to mobs and slots, the Edit Protocol
|
||||
transition rules and sequence length arithmetic; every stored form round-trips through the
|
||||
container.
|
||||
- `src/omf/tests.rs`: the Bento label and TOC, the required properties of every OMF class
|
||||
written, reference resolution, embedded WAVE data, sequence lengths in samples.
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "filmcraft-mxf"
|
||||
description = "Clean-room MXF (SMPTE ST 377-1) demuxer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence"
|
||||
description = "Clean-room MXF (SMPTE ST 377-1) demuxer and writer: OP1a and OP-Atom, index tables, AVC/VC-3/ProRes/MPEG-2/PCM essence"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# filmcraft-mxf
|
||||
|
||||
A clean-room MXF demuxer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for
|
||||
A clean-room MXF demuxer and writer. Layer L0: no dependencies beyond `std`, no `unsafe`, builds for
|
||||
`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libMXF, bmx, MXFLib) was consulted; FFmpeg is
|
||||
used only as an external fixture generator and test oracle.
|
||||
|
||||
|
|
@ -22,7 +22,8 @@ Implemented from the SMPTE documents (editions used):
|
|||
| SMPTE ST 386 | 2004 | D-10 (IMX) mapping: container label, sound element type |
|
||||
| SMPTE ST 2019-4 | 2009 | VC-3 (DNxHD / DNxHR) mapping |
|
||||
| SMPTE RDD 44 | 2017 | Apple ProRes mapping |
|
||||
| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns |
|
||||
| SMPTE RP 224 / RP 210 | registers | labels: data definitions, picture coding, essence containers, operational patterns; property labels of the primer pack (writer) |
|
||||
| SMPTE ST 330 | 2011 | basic UMIDs of the written packages |
|
||||
|
||||
## Reading a file
|
||||
|
||||
|
|
@ -72,6 +73,57 @@ Not supported: OP1b/OP2x+ edit lists beyond the first source clip, external esse
|
|||
material packages whose other tracks live in other files are opened track by track), partial or
|
||||
encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored).
|
||||
|
||||
## Writing a file
|
||||
|
||||
```rust
|
||||
use filmcraft_mxf::*;
|
||||
let ids = PackageIds::from_seed("unique seed (path + time)", "Clip name"); // material + file UMIDs
|
||||
let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids);
|
||||
cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 1920, 1080));
|
||||
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 24 }];
|
||||
cfg.timecode = Some(StartTimecode { frames: 90_000, rate: Rational::new(25, 1), drop_frame: false });
|
||||
let mut w = MxfWriter::new(std::io::BufWriter::new(std::fs::File::create("out.mxf")?), cfg)?;
|
||||
w.push_picture(&frame_bytes, FrameInfo::intra())?; // stored order; AVC: key / P / B + display position
|
||||
w.push_sound(0, &interleaved_le_pcm)?; // any chunking: content packages are cut per edit unit
|
||||
w.finish()?;
|
||||
```
|
||||
|
||||
- **Streaming.** Any `Write + Seek` sink. `new` writes the header partition (open, incomplete,
|
||||
durations unknown) and a body partition; essence follows as it is pushed; `finish` writes the
|
||||
footer partition (closed, complete header metadata with the final durations and the index table
|
||||
segments), the random index pack, then rewrites the header partition pack and header metadata in
|
||||
place (the metadata's size does not depend on the durations; a reserved fill item absorbs any
|
||||
difference) and the body partition pack's footer offset. Only the index entries (11 bytes per
|
||||
edit unit, plus 4 per sound slice) are kept in memory.
|
||||
- **OP1a** (ST 378): one content package per edit unit, frame wrapping (ST 379-1): the picture
|
||||
element then one element per sound track. Sound is buffered and cut into edit units exactly
|
||||
(`floor((n+1)·rate·den/num) − floor(n·rate·den/num)` samples: 1601/1602 at 29.97). Pictures
|
||||
without their sound yet wait; `finish` pads the last units' sound with silence. A multiple
|
||||
descriptor with one sub-descriptor per track (linked track ids), the "multiple wrappings"
|
||||
container label.
|
||||
- **OP-Atom** (ST 390, Avid style): exactly one essence track per file, clip-wrapped (one element
|
||||
with a 9-byte BER length patched by `finish`). Several atom files of one clip can share the
|
||||
material package UMID with distinct track ids (`WriterConfig::first_track_id`). Sound files may
|
||||
use the sample rate as the edit rate (a constant-bytes index) or the frame rate (VBR entries).
|
||||
- **Index tables** (ST 377-1 §11): VBR entries per edit unit — stream offset (from the first
|
||||
essence byte; clip-wrapped: from the element value), key-frame offset, flags (`0x80` random
|
||||
access, `0xC0` AVC IDR with its parameter sets, `0x22` P, `0x33` B) and temporal offsets from the
|
||||
display positions (`TO[d] = stored(d) − d`); a delta entry and a slice per sound element. Split
|
||||
into segments of at most ~60 KB (the entry array is a 2-byte-length local set item).
|
||||
- **Metadata.** Preface, identification (company / product / version / modification date),
|
||||
content storage, essence container data (BodySID 1, IndexSID 2), material and file packages
|
||||
(`PackageIds`: UMIDs and names) with a timecode track (start, rounded base, drop frame) and one
|
||||
timeline track per essence (source clip → file package track; track numbers = element keys),
|
||||
CDCI picture descriptors (size, aspect ratio, depth, subsampling, black / white / range, BT.709 /
|
||||
BT.2020 PQ / HLG colour labels, coding label) and wave audio descriptors.
|
||||
- **Mappings.** VC-3 (ST 2019-4: coding label `…71 cc` from the compression id, container `02 11`,
|
||||
element types 0x0C / 0x0D), ProRes (RDD 44: coding label profile byte, container `02 1C`),
|
||||
AVC byte stream (ST 381-3: container `02 10 60`, coding label from `profile_idc`; Annex B access
|
||||
units with in-band SPS / PPS — the AVC sub-descriptor is not written), Broadcast Wave PCM
|
||||
(ST 382: container `02 06 01/02`, element types 0x01 / 0x02).
|
||||
- `write_opatom_pcm(&OpAtomPcm, &[i32])` builds a complete in-memory OP-Atom PCM file (AAF media
|
||||
consolidation); `encode_pcm` interleaves planar f32 as little-endian PCM.
|
||||
|
||||
## Tests
|
||||
|
||||
- `src/tests.rs`: a small OP1a writer builds synthetic files (temporal offsets, key flags, PCM,
|
||||
|
|
@ -82,3 +134,13 @@ encrypted essence (ST 429-6), and descriptive metadata (parsed sets are ignored)
|
|||
timecode, DNxHR LB (±2), ProRes 422 (±1), MPEG-2 long GOP, XDCAM HD422 and D-10 (±4),
|
||||
OP-Atom VC-3 (clip-wrapped) and PCM, D-10 AES3 audio; PCM sample-exact in every file;
|
||||
truncated and corrupted files. `cargo xtask fixtures codecs` pre-generates them.
|
||||
- `src/write/tests.rs`: writer output read back by `open`: OP1a with two sound tracks pushed in
|
||||
uneven chunks (durations, codec label, aspect, PCM exact, index slices, closed partitions),
|
||||
29.97 sound per edit unit (1601/1602), a long-GOP AVC index (presentation order, key and B
|
||||
flags), OP-Atom picture (clip wrapping) and PCM files at both edit rates, multi-segment index
|
||||
of 12 000 units, UMIDs; truncation and random mutation of written files never panic.
|
||||
- `crates/export/src/mxf_tests.rs` (ffprobe / ffmpeg as external oracles): OP1a exports with
|
||||
DNxHR, ProRes and H.264 — ffprobe reports the OP1a label, codec, 24 frames, frame rate, duration
|
||||
and `01:00:00:00` timecode; ffmpeg decodes the MXF picture exactly as it decodes the same
|
||||
essence exported as MOV, and its PCM equals our demuxer's sample for sample; OP-Atom exports —
|
||||
picture file and one mono file per channel, each channel equal to the WAV export's.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
//! presentation position from the index temporal offsets, random-access flag) and PCM chunk
|
||||
//! tables for sound, plus the start timecode of the material package.
|
||||
//!
|
||||
//! [`MxfWriter`] ([`write`]) writes OP1a and OP-Atom files (VC-3, ProRes, AVC byte stream, PCM)
|
||||
//! with index tables and start timecode, streaming to any `Write + Seek` sink.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||
|
||||
mod essence;
|
||||
|
|
@ -20,12 +23,17 @@ mod index;
|
|||
mod klv;
|
||||
mod meta;
|
||||
mod source;
|
||||
pub mod write;
|
||||
|
||||
pub use essence::{Codec, PictureInfo, SoundFormat, SoundInfo, aes3_element_samples, decode_pcm};
|
||||
pub use file::{Chunk, EssenceTrack, MxfFile, OperationalPattern, Partition, PartitionKind, Sample, Timecode, TrackKind, Wrapping, open};
|
||||
pub use index::{IndexEntry, IndexSegment};
|
||||
pub use klv::{Rational, Ul};
|
||||
pub use source::ByteSource;
|
||||
pub use write::{
|
||||
CodedKind, ColorSpace, FrameInfo, MxfWriter, OpAtomPcm, PackageIds, Pattern, PictureCoding, PictureDesc, SoundDesc, StartTimecode, Timestamp, Umid,
|
||||
WriterConfig, encode_pcm, write_opatom_pcm,
|
||||
};
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Labels and keys used by the writer (SMPTE RP 224 / RP 210 registers, ST 377-1, ST 379-2 and the
|
||||
//! essence mapping documents).
|
||||
|
||||
/// Header metadata set key: 06 0E 2B 34 02 53 01 01 0D 01 01 01 01 01 tt 00.
|
||||
pub fn set_key(kind: u8) -> [u8; 16] {
|
||||
[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, kind, 0x00]
|
||||
}
|
||||
|
||||
/// Partition pack key (kind 2 header, 3 body, 4 footer; status 1 open incomplete … 4 closed complete).
|
||||
pub fn partition_key(kind: u8, status: u8) -> [u8; 16] {
|
||||
[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, kind, status, 0x00]
|
||||
}
|
||||
|
||||
pub const PRIMER_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x05, 0x01, 0x00];
|
||||
pub const INDEX_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x10, 0x01, 0x00];
|
||||
pub const RIP_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x11, 0x01, 0x00];
|
||||
|
||||
/// OP1a (ST 378); byte 14 qualifiers: bit 3 multi-track, bit 0 always set.
|
||||
pub fn op1a(multi_track: bool) -> [u8; 16] {
|
||||
[0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x01, if multi_track { 0x09 } else { 0x01 }, 0x00]
|
||||
}
|
||||
|
||||
/// OP-Atom (ST 390).
|
||||
pub const OP_ATOM: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x02, 0x0D, 0x01, 0x02, 0x01, 0x10, 0x00, 0x00, 0x00];
|
||||
|
||||
/// Data definitions (RP 224).
|
||||
pub const DEF_PICTURE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00];
|
||||
pub const DEF_SOUND: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00];
|
||||
pub const DEF_TIMECODE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00];
|
||||
|
||||
/// Generic container: multiple wrappings (ST 379-2).
|
||||
pub const EC_MULTIPLE: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x03, 0x0D, 0x01, 0x03, 0x01, 0x02, 0x7F, 0x01, 0x00];
|
||||
|
||||
/// Essence container label of a generic-container mapping: 06 0E 2B 34 04 01 01 vv 0D 01 03 01 02 mm ww xx.
|
||||
pub fn essence_container(version: u8, mapping: u8, b14: u8, b15: u8) -> [u8; 16] {
|
||||
[0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, version, 0x0D, 0x01, 0x03, 0x01, 0x02, mapping, b14, b15]
|
||||
}
|
||||
|
||||
/// Generic container element key: item type (0x15 picture, 0x16 sound), element count, element type, number.
|
||||
pub fn element_key(item: u8, count: u8, kind: u8, number: u8) -> [u8; 16] {
|
||||
[0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, item, count, kind, number]
|
||||
}
|
||||
|
||||
pub const KLV_FILL: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x10, 0x01, 0x00, 0x00, 0x00];
|
||||
|
||||
/// Colour labels (RP 224): transfer characteristics, coding equations, colour primaries.
|
||||
pub const TC_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01, 0x02, 0x00, 0x00];
|
||||
pub const TC_PQ: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0A, 0x00, 0x00];
|
||||
pub const TC_HLG: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x01, 0x0B, 0x00, 0x00];
|
||||
pub const CE_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x02, 0x02, 0x00, 0x00];
|
||||
pub const CE_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x02, 0x06, 0x00, 0x00];
|
||||
pub const CP_709: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x06, 0x04, 0x01, 0x01, 0x01, 0x03, 0x03, 0x00, 0x00];
|
||||
pub const CP_2020: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x01, 0x01, 0x03, 0x04, 0x00, 0x00];
|
||||
|
||||
/// Static local tags written, with their property labels (RP 210), for the primer pack.
|
||||
pub const PRIMER: &[(u16, [u8; 16])] = &[
|
||||
(0x3C0A, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x02])),
|
||||
(0x3B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x04])),
|
||||
(0x3B05, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x05])),
|
||||
(0x3B07, ul(&[0x01, 0x01, 0x01, 0x02, 0x03, 0x01, 0x02, 0x01, 0x04])),
|
||||
(0x3B06, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x04])),
|
||||
(0x3B03, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x01])),
|
||||
(0x3B09, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x03])),
|
||||
(0x3B0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x01])),
|
||||
(0x3B0B, ul(&[0x01, 0x01, 0x01, 0x05, 0x01, 0x02, 0x02, 0x10, 0x02, 0x02])),
|
||||
(0x3C09, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x01])),
|
||||
(0x3C01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x02, 0x01])),
|
||||
(0x3C02, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x03, 0x01])),
|
||||
(0x3C04, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x05, 0x01])),
|
||||
(0x3C05, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x20, 0x07, 0x01, 0x07])),
|
||||
(0x3C06, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x03])),
|
||||
(0x1901, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x01])),
|
||||
(0x1902, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x05, 0x02])),
|
||||
(0x2701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x06, 0x01])),
|
||||
(0x3F06, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x05])),
|
||||
(0x3F07, ul(&[0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x04, 0x04])),
|
||||
(0x4401, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x15, 0x10])),
|
||||
(0x4402, ul(&[0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x03, 0x02, 0x01])),
|
||||
(0x4405, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x01, 0x03])),
|
||||
(0x4404, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x10, 0x02, 0x05])),
|
||||
(0x4403, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x05])),
|
||||
(0x4701, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x03])),
|
||||
(0x4801, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x01])),
|
||||
(0x4804, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x04, 0x01, 0x03])),
|
||||
(0x4802, ul(&[0x01, 0x01, 0x01, 0x02, 0x01, 0x07, 0x01, 0x02, 0x01])),
|
||||
(0x4803, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x02, 0x04])),
|
||||
(0x4B01, ul(&[0x01, 0x01, 0x01, 0x02, 0x05, 0x30, 0x04, 0x05])),
|
||||
(0x4B02, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x03])),
|
||||
(0x0201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x07, 0x01])),
|
||||
(0x0202, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x02, 0x01, 0x01, 0x03])),
|
||||
(0x1001, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x06, 0x09])),
|
||||
(0x1201, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x04])),
|
||||
(0x1101, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x01])),
|
||||
(0x1102, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x03, 0x02])),
|
||||
(0x1502, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x04, 0x01, 0x01, 0x02, 0x06])),
|
||||
(0x1501, ul(&[0x01, 0x01, 0x01, 0x02, 0x07, 0x02, 0x01, 0x03, 0x01, 0x05])),
|
||||
(0x1503, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x04, 0x01, 0x01, 0x05])),
|
||||
(0x3F01, ul(&[0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01, 0x04, 0x06, 0x0B])),
|
||||
(0x3001, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x01])),
|
||||
(0x3002, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x06, 0x01, 0x02])),
|
||||
(0x3004, ul(&[0x01, 0x01, 0x01, 0x02, 0x06, 0x01, 0x01, 0x04, 0x01, 0x02])),
|
||||
(0x3006, ul(&[0x01, 0x01, 0x01, 0x05, 0x06, 0x01, 0x01, 0x03, 0x05])),
|
||||
(0x320C, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x03, 0x01, 0x04])),
|
||||
(0x3203, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x02])),
|
||||
(0x3202, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x02, 0x01])),
|
||||
(0x3205, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x08])),
|
||||
(0x3204, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x07])),
|
||||
(0x3209, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0C])),
|
||||
(0x3208, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x0B])),
|
||||
(0x320E, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x01, 0x01, 0x01])),
|
||||
(0x320D, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x03, 0x02, 0x05])),
|
||||
(0x3201, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x06, 0x01])),
|
||||
(0x3210, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02])),
|
||||
(0x321A, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x02, 0x01, 0x01, 0x03, 0x01])),
|
||||
(0x3219, ul(&[0x01, 0x01, 0x01, 0x09, 0x04, 0x01, 0x02, 0x01, 0x01, 0x06, 0x01])),
|
||||
(0x3301, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x0A])),
|
||||
(0x3302, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x05])),
|
||||
(0x3308, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x01, 0x10])),
|
||||
(0x3303, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x01, 0x06])),
|
||||
(0x3304, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x03])),
|
||||
(0x3305, ul(&[0x01, 0x01, 0x01, 0x01, 0x04, 0x01, 0x05, 0x03, 0x04])),
|
||||
(0x3306, ul(&[0x01, 0x01, 0x01, 0x02, 0x04, 0x01, 0x05, 0x03, 0x05])),
|
||||
(0x3D03, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x01, 0x01, 0x01])),
|
||||
(0x3D02, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x01, 0x04])),
|
||||
(0x3D07, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x01, 0x01, 0x04])),
|
||||
(0x3D01, ul(&[0x01, 0x01, 0x01, 0x04, 0x04, 0x02, 0x03, 0x03, 0x04])),
|
||||
(0x3D0A, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x02, 0x01])),
|
||||
(0x3D09, ul(&[0x01, 0x01, 0x01, 0x05, 0x04, 0x02, 0x03, 0x03, 0x05])),
|
||||
];
|
||||
|
||||
/// A property label: 06 0E 2B 34 followed by `rest`, zero padded.
|
||||
const fn ul(rest: &[u8]) -> [u8; 16] {
|
||||
let mut k = [0u8; 16];
|
||||
k[0] = 0x06;
|
||||
k[1] = 0x0E;
|
||||
k[2] = 0x2B;
|
||||
k[3] = 0x34;
|
||||
let mut i = 0;
|
||||
while i < rest.len() && i < 12 {
|
||||
k[4 + i] = rest[i];
|
||||
i += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,318 @@
|
|||
//! Writer tests: files read back with our own demuxer; truncation and mutation never panic.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
use crate::{Codec, OperationalPattern, PartitionKind, TrackKind, Wrapping, open};
|
||||
|
||||
fn prores_like(i: usize) -> Vec<u8> {
|
||||
let mut v = vec![0, 0, 0, 40];
|
||||
v.extend_from_slice(b"icpf");
|
||||
v.extend(std::iter::repeat_n(i as u8, 32 + i * 3));
|
||||
v
|
||||
}
|
||||
|
||||
fn vc3_like(i: usize) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x02, 0x80, 0x01];
|
||||
v.extend(std::iter::repeat_n(i as u8 ^ 0x5A, 500 + 7 * i));
|
||||
v
|
||||
}
|
||||
|
||||
/// 16-bit stereo PCM: frame k = (k, -k).
|
||||
fn pcm16(start: usize, n: usize) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for k in start..start + n {
|
||||
let s = (k % 30000) as i16;
|
||||
v.extend_from_slice(&s.to_le_bytes());
|
||||
v.extend_from_slice(&(-s).to_le_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 24-bit mono PCM: frame k = 1000 * k (wrapping inside the 24-bit range).
|
||||
fn pcm24(start: usize, n: usize) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for k in start..start + n {
|
||||
let s = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000;
|
||||
v.extend_from_slice(&s.to_le_bytes()[..3]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig {
|
||||
let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("test", "Test Clip"));
|
||||
cfg.picture = Some(PictureDesc::new(coding, 64, 36));
|
||||
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }, SoundDesc { sample_rate: 48_000, channels: 1, bits: 24 }];
|
||||
cfg.timecode = Some(StartTimecode { frames: 90_000, rate, drop_frame: false });
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks.
|
||||
fn build_op1a() -> Vec<u8> {
|
||||
let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 });
|
||||
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
|
||||
let total = 10 * 1920;
|
||||
let (mut a, mut b) = (0, 0);
|
||||
for i in 0..10 {
|
||||
// sound before and after the picture, in chunks unrelated to the edit units
|
||||
let n = 1500.min(total - a);
|
||||
w.push_sound(0, &pcm16(a, n)).unwrap();
|
||||
a += n;
|
||||
w.push_picture(&prores_like(i), FrameInfo::intra()).unwrap();
|
||||
let m = 2500.min(total - b);
|
||||
w.push_sound(1, &pcm24(b, m)).unwrap();
|
||||
b += m;
|
||||
}
|
||||
w.push_sound(0, &pcm16(a, total - a)).unwrap();
|
||||
assert_eq!(w.edit_units(), 10);
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op1a_reads_back() {
|
||||
let f = build_op1a();
|
||||
let m = open(&f).unwrap();
|
||||
assert!(m.warnings.is_empty(), "{:?}", m.warnings);
|
||||
assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 });
|
||||
assert_eq!(m.material_package_name.as_deref(), Some("Test Clip"));
|
||||
assert_eq!(m.product_name.as_deref(), Some("FilmCraft"));
|
||||
let tc = m.timecode.unwrap();
|
||||
assert_eq!(tc.format(), "01:00:00:00");
|
||||
// header rewritten closed and complete; body and footer partitions; footer metadata used
|
||||
let kinds: Vec<(PartitionKind, u8)> = m.partitions.iter().map(|p| (p.kind, p.status)).collect();
|
||||
assert_eq!(kinds, vec![(PartitionKind::Header, 4), (PartitionKind::Body, 4), (PartitionKind::Footer, 4)]);
|
||||
assert!(m.partitions.iter().all(|p| p.footer_partition == m.partitions[2].offset));
|
||||
assert_eq!(m.tracks.len(), 3);
|
||||
let v = m.track_of_kind(TrackKind::Picture).unwrap();
|
||||
let t = &m.tracks[v];
|
||||
assert_eq!(t.codec, Codec::ProRes { profile: Some(3) });
|
||||
assert_eq!(t.duration, Some(10));
|
||||
assert_eq!(t.edit_rate, Rational::new(25, 1));
|
||||
assert_eq!(t.wrapping, Wrapping::Frame);
|
||||
assert!(t.indexed || t.codec.intra_only());
|
||||
let p = t.picture.as_ref().unwrap();
|
||||
assert_eq!((p.frame_width(), p.frame_height(), p.component_depth), (64, 36, 10));
|
||||
assert_eq!(p.aspect_ratio, Rational::new(16, 9));
|
||||
for i in 0..10 {
|
||||
assert_eq!(m.read_sample(&f, v, i).unwrap(), prores_like(i));
|
||||
}
|
||||
let sounds: Vec<usize> = (0..m.tracks.len()).filter(|&i| m.tracks[i].kind == TrackKind::Sound).collect();
|
||||
assert_eq!(sounds.len(), 2);
|
||||
let s0 = &m.tracks[sounds[0]];
|
||||
assert_eq!(s0.codec, Codec::Pcm);
|
||||
assert_eq!(s0.stored_sample_frames(), 10 * 1920);
|
||||
assert_eq!(s0.chunks.len(), 10);
|
||||
let pcm = m.read_pcm(&f, sounds[0], 1900, 100).unwrap();
|
||||
for k in 0..100 {
|
||||
assert_eq!(pcm[0][k], (1900 + k) as f32 / 32768.0);
|
||||
assert_eq!(pcm[1][k], -((1900 + k) as f32) / 32768.0);
|
||||
}
|
||||
let s1 = &m.tracks[sounds[1]];
|
||||
assert_eq!(s1.sound.as_ref().unwrap().bits, 24);
|
||||
let pcm = m.read_pcm(&f, sounds[1], 0, 10 * 1920).unwrap();
|
||||
for k in (0..10 * 1920).step_by(97) {
|
||||
let want = ((k as i64 * 1000) % 8_000_000) as i32 - 4_000_000;
|
||||
assert_eq!(pcm[0][k], want as f32 / 8_388_608.0, "sample {k}");
|
||||
}
|
||||
// index: one entry per content package, two sound slices
|
||||
let entries: usize = m.index_segments.iter().map(|s| s.entries.len()).sum();
|
||||
assert_eq!(entries, 10);
|
||||
assert_eq!(m.index_segments[0].slice_count, 2);
|
||||
assert_eq!(m.index_segments[0].delta_entries.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_sound_follows_the_edit_unit_pattern() {
|
||||
let rate = Rational::new(30000, 1001);
|
||||
let mut cfg = op1a_config(rate, PictureCoding::Vc3 { cid: 1274 });
|
||||
cfg.sound.truncate(1);
|
||||
cfg.timecode = Some(StartTimecode { frames: 107_892, rate, drop_frame: true });
|
||||
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
|
||||
let n = 12;
|
||||
let total = (n as u64 * 48_000 * 1001 / 30_000) as usize;
|
||||
w.push_sound(0, &pcm16(0, total)).unwrap();
|
||||
for i in 0..n {
|
||||
w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap();
|
||||
}
|
||||
let f = w.finish().unwrap().into_inner();
|
||||
let m = open(&f).unwrap();
|
||||
assert_eq!(m.timecode.unwrap().format(), "01:00:00;00");
|
||||
let a = m.track_of_kind(TrackKind::Sound).unwrap();
|
||||
let sizes: Vec<u64> = m.tracks[a].chunks.iter().map(|c| c.samples).collect();
|
||||
assert_eq!(sizes[..5], [1601, 1602, 1601, 1602, 1602]);
|
||||
assert_eq!(m.tracks[a].stored_sample_frames(), total as u64);
|
||||
let pcm = m.read_pcm(&f, a, 0, total).unwrap();
|
||||
assert!((0..total).all(|k| pcm[0][k] == (k % 30000) as f32 / 32768.0));
|
||||
let v = m.track_of_kind(TrackKind::Picture).unwrap();
|
||||
assert_eq!(m.tracks[v].codec, Codec::Vc3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_gop_index_gives_presentation_order_and_key_frames() {
|
||||
let rate = Rational::new(25, 1);
|
||||
let mut cfg = op1a_config(rate, PictureCoding::Avc { profile_idc: 100, intra: false });
|
||||
cfg.sound.clear();
|
||||
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
|
||||
// stored I0 P3 B1 B2 P6 B4 B5 I7(IDR)
|
||||
let order = [(0, CodedKind::Intra, true), (3, CodedKind::Predicted, false), (1, CodedKind::Bidirectional, false), (2, CodedKind::Bidirectional, false)];
|
||||
let more = [(6, CodedKind::Predicted, false), (4, CodedKind::Bidirectional, false), (5, CodedKind::Bidirectional, false), (7, CodedKind::Intra, true)];
|
||||
for (i, (d, kind, key)) in order.iter().chain(more.iter()).enumerate() {
|
||||
let au = vec![0, 0, 0, 1, if *key { 0x65 } else { 0x41 }, i as u8];
|
||||
w.push_picture(&au, FrameInfo { key: *key, kind: *kind, display: Some(*d) }).unwrap();
|
||||
}
|
||||
let f = w.finish().unwrap().into_inner();
|
||||
let m = open(&f).unwrap();
|
||||
let t = &m.tracks[0];
|
||||
assert_eq!(t.codec, Codec::Avc { intra: false });
|
||||
assert!(t.temporal_offsets && t.indexed);
|
||||
let pts: Vec<i64> = t.samples.iter().map(|s| s.pts).collect();
|
||||
assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5, 7]);
|
||||
let keys: Vec<bool> = t.samples.iter().map(|s| s.key).collect();
|
||||
assert_eq!(keys, vec![true, false, false, false, false, false, false, true]);
|
||||
let b: Vec<bool> = t.samples.iter().map(|s| s.b_picture).collect();
|
||||
assert_eq!(b, vec![false, false, true, true, false, true, true, false]);
|
||||
assert_eq!(t.sample_at(4), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_atom_picture_is_clip_wrapped() {
|
||||
let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(24, 1), PackageIds::from_seed("atom-v", "Atom V"));
|
||||
cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 64, 36));
|
||||
cfg.timecode = Some(StartTimecode { frames: 86_400, rate: Rational::new(24, 1), drop_frame: false });
|
||||
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
|
||||
for i in 0..9 {
|
||||
w.push_picture(&vc3_like(i), FrameInfo::intra()).unwrap();
|
||||
}
|
||||
assert!(w.push_sound(0, &[0, 0]).is_err());
|
||||
let f = w.finish().unwrap().into_inner();
|
||||
let m = open(&f).unwrap();
|
||||
assert!(m.warnings.is_empty(), "{:?}", m.warnings);
|
||||
assert_eq!(m.operational_pattern, OperationalPattern::Atom);
|
||||
assert_eq!(m.timecode.unwrap().format(), "01:00:00:00");
|
||||
let t = &m.tracks[0];
|
||||
assert_eq!(t.wrapping, Wrapping::Clip);
|
||||
assert_eq!(t.codec, Codec::Vc3);
|
||||
assert_eq!(t.samples.len(), 9);
|
||||
for i in 0..9 {
|
||||
assert_eq!(m.read_sample(&f, 0, i).unwrap(), vc3_like(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_atom_pcm_helper() {
|
||||
let ids = PackageIds::from_seed("atom-a", "Atom A1");
|
||||
let samples: Vec<i32> = (0..4801).map(|k| (k * 7 % 65536) - 32768).collect();
|
||||
for rate in [None, Some(Rational::new(25, 1))] {
|
||||
let opts = OpAtomPcm { sample_rate: 48_000, bits: 16, channels: 1, edit_rate: rate, ids: ids.clone(), timecode: None };
|
||||
let f = write_opatom_pcm(&opts, &samples).unwrap();
|
||||
let m = open(&f).unwrap();
|
||||
assert_eq!(m.operational_pattern, OperationalPattern::Atom);
|
||||
let t = &m.tracks[0];
|
||||
assert_eq!(t.kind, TrackKind::Sound);
|
||||
assert_eq!(t.stored_sample_frames(), 4801);
|
||||
assert_eq!(t.duration, Some(if rate.is_some() { 3 } else { 4801 }));
|
||||
let pcm = m.read_pcm(&f, 0, 0, 4801).unwrap();
|
||||
assert!(samples.iter().zip(&pcm[0]).all(|(s, p)| *s as f32 / 32768.0 == *p));
|
||||
}
|
||||
// 24-bit stereo
|
||||
let opts = OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 2, edit_rate: None, ids, timecode: None };
|
||||
let f = write_opatom_pcm(&opts, &[8_388_607, -8_388_608, 1, -1]).unwrap();
|
||||
let m = open(&f).unwrap();
|
||||
let pcm = m.read_pcm(&f, 0, 0, 2).unwrap();
|
||||
assert_eq!(pcm[0], vec![8_388_607.0 / 8_388_608.0, 1.0 / 8_388_608.0]);
|
||||
assert_eq!(pcm[1], vec![-1.0, -1.0 / 8_388_608.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_ids_are_written() {
|
||||
let f = build_op1a();
|
||||
let ids = PackageIds::from_seed("test", "Test Clip");
|
||||
assert!(f.windows(32).any(|w| w == ids.material.0));
|
||||
assert!(f.windows(32).any(|w| w == ids.file.0));
|
||||
assert_ne!(ids.material, ids.file);
|
||||
assert_eq!(Umid::from_seed(b"x"), Umid::from_seed(b"x"));
|
||||
assert_ne!(Umid::from_seed(b"x"), Umid::from_seed(b"y"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_files_split_the_index_into_segments() {
|
||||
let mut cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 4 });
|
||||
cfg.sound.clear();
|
||||
let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap();
|
||||
for i in 0..12_000 {
|
||||
w.push_picture(&[0, 0, 0, 9, b'i', b'c', b'p', b'f', i as u8], FrameInfo::intra()).unwrap();
|
||||
}
|
||||
let f = w.finish().unwrap().into_inner();
|
||||
let m = open(&f).unwrap();
|
||||
assert!(m.index_segments.len() >= 2);
|
||||
assert_eq!(m.index_segments.iter().map(|s| s.entries.len()).sum::<usize>(), 12_000);
|
||||
assert_eq!(m.tracks[0].samples.len(), 12_000);
|
||||
assert_eq!(m.read_sample(&f, 0, 11_999).unwrap()[8], (11_999 % 256) as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_configs() {
|
||||
let ids = PackageIds::from_seed("x", "x");
|
||||
let cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), ids.clone());
|
||||
assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err());
|
||||
let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(25, 1), ids);
|
||||
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 1, bits: 16 }; 2];
|
||||
assert!(MxfWriter::new(Cursor::new(Vec::new()), cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps() {
|
||||
assert_eq!(Timestamp::from_unix(0), Timestamp { year: 1970, month: 1, day: 1, ..Default::default() });
|
||||
let t = Timestamp::from_unix(1_700_000_000);
|
||||
assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (2023, 11, 14, 22, 13, 20));
|
||||
let t = Timestamp::from_unix(951_782_400); // 2000-02-29
|
||||
assert_eq!((t.year, t.month, t.day), (2000, 2, 29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_mutated_files_never_panic() {
|
||||
let f = build_op1a();
|
||||
let mut opened = 0;
|
||||
for cut in (0..f.len()).step_by(41) {
|
||||
let part = f[..cut].to_vec();
|
||||
if let Ok(m) = open(&part) {
|
||||
opened += 1;
|
||||
for (ti, t) in m.tracks.iter().enumerate() {
|
||||
for i in 0..t.samples.len() {
|
||||
assert!(m.read_sample(&part, ti, i).is_ok(), "cut {cut}");
|
||||
}
|
||||
let _ = m.read_pcm(&part, ti, 0, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(opened > 20, "a truncated file with a complete header still opens ({opened})");
|
||||
let atom = write_opatom_pcm(
|
||||
&OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 1, edit_rate: None, ids: PackageIds::from_seed("m", "m"), timecode: None },
|
||||
&(0..3000).collect::<Vec<i32>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut seed = 0x9E37_79B9u64;
|
||||
for src in [&f, &atom] {
|
||||
for cut in (0..src.len()).step_by(53) {
|
||||
let _ = open(&src[..cut].to_vec()).map(|m| m.read_pcm(&src[..cut].to_vec(), 0, 0, 100).ok());
|
||||
}
|
||||
for _ in 0..400 {
|
||||
let mut g = src.clone();
|
||||
for _ in 0..6 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let at = (seed as usize) % g.len();
|
||||
g[at] = (seed >> 40) as u8;
|
||||
}
|
||||
if let Ok(m) = open(&g) {
|
||||
for (ti, t) in m.tracks.iter().enumerate() {
|
||||
for i in 0..t.samples.len().min(12) {
|
||||
let _ = m.read_sample(&g, ti, i);
|
||||
}
|
||||
let _ = m.read_pcm(&g, ti, 0, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,4 +40,5 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
|||
[dev-dependencies]
|
||||
pollster = "0.4"
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-interchange = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
|
|
|
|||
|
|
@ -665,7 +665,7 @@ impl FilmcraftApp {
|
|||
.iter()
|
||||
.chain(filmcraft_media::AUDIO_EXTENSIONS)
|
||||
.chain(filmcraft_media::STILL_EXTENSIONS)
|
||||
.chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio"])
|
||||
.chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"])
|
||||
.copied()
|
||||
.collect();
|
||||
let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -286,6 +286,11 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
crate::panels::shortcuts_dialog::open(app);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// File ▸ Export ▸ AAF… / OMF… open their settings dialogs; with a path they export directly.
|
||||
"file.exportAaf" | "file.exportOmf" if params.get("path").is_none() => {
|
||||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
return crate::panels::interchange_export::open(app, ctx, id);
|
||||
}
|
||||
// Audio Gain from the menu or G opens the dialog; with params it applies directly.
|
||||
"clip.audioGain" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
filmcraft_engine::find_command("clip.audioGain").map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
crate::panels::monitor_view::dialogs(app, ctx);
|
||||
crate::panels::voiceover::show(app, ctx);
|
||||
crate::panels::remix::show(app, ctx);
|
||||
crate::panels::interchange_export::show(app, ctx);
|
||||
crate::panels::project_dialogs::show(app, ctx);
|
||||
crate::panels::media_browser::dialogs(app, ctx);
|
||||
let Some(d) = app.dialog else { return };
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
|
||||
use filmcraft_engine::export::{
|
||||
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, Placement, Scaling, TextOverlay, builtin_presets, format_bytes,
|
||||
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, H264Profile, Multiplexer, MxfVideoCodec, Placement, Scaling, TextOverlay, builtin_presets,
|
||||
format_bytes,
|
||||
};
|
||||
use filmcraft_engine::time::{FrameRate, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -458,7 +459,11 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) {
|
||||
video_section(ui, &mut reg, s, &t, seq_w, seq_h);
|
||||
}
|
||||
if (s.has_audio() || !s.has_video() || s.format == Format::H264 || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg))
|
||||
if (s.has_audio()
|
||||
|| !s.has_video()
|
||||
|| s.format == Format::H264
|
||||
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Mjpeg)
|
||||
|| s.format.is_mxf())
|
||||
&& !s.is_image_sequence()
|
||||
&& s.format != Format::Gif
|
||||
&& section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t)
|
||||
|
|
@ -579,7 +584,15 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
|||
s.pixel_aspect = PARS[i].1;
|
||||
}
|
||||
});
|
||||
match s.format {
|
||||
if s.format.is_mxf() {
|
||||
row(ui, t, "Video Codec", |ui| {
|
||||
let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect();
|
||||
if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) {
|
||||
s.mxf_video_codec = MxfVideoCodec::ALL[i];
|
||||
}
|
||||
});
|
||||
}
|
||||
match s.video_format() {
|
||||
Format::H264 => {
|
||||
row(ui, t, "Profile", |ui| {
|
||||
let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High];
|
||||
|
|
@ -683,7 +696,7 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
|||
}
|
||||
}
|
||||
row(ui, t, "Audio Format", |ui| {
|
||||
let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only;
|
||||
let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf();
|
||||
let cur = match s.audio_codec() {
|
||||
AudioCodec::Aac => "AAC",
|
||||
_ => "Uncompressed (PCM)",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//! File ▸ Export ▸ AAF… and OMF…: the export settings dialogs. OK asks for the file name (the
|
||||
//! host's save panel) and runs `file.exportAaf` / `file.exportOmf` with the chosen settings; with
|
||||
//! parameters (`path` …) the menu commands run directly.
|
||||
//!
|
||||
//! Automation ids (`<p>` is `aafExport` or `omfExport`): `<p>.mixdownVideo` (AAF),
|
||||
//! `<p>.breakoutToMono`, `<p>.audio.<embedded|separate|linked>` (linked: AAF only),
|
||||
//! `<p>.audioFormat.<wav|aiff|mxf>` (mxf: AAF only), `<p>.sampleRate.<rate>`, `<p>.bitDepth.<16|24>`, `<p>.trimAudio`,
|
||||
//! `<p>.handles` (frames), `<p>.renderAudioEffects`, `<p>.title` (OMF), `<p>.ok`, `<p>.cancel`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Draft {
|
||||
omf: bool,
|
||||
title: String,
|
||||
mixdown_video: bool,
|
||||
breakout: bool,
|
||||
audio: &'static str,
|
||||
format: &'static str,
|
||||
sample_rate: u32,
|
||||
bits: u16,
|
||||
trim: bool,
|
||||
handles: i64,
|
||||
render_effects: bool,
|
||||
}
|
||||
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("interchange-export-draft")
|
||||
}
|
||||
|
||||
const RATES: [u32; 4] = [32_000, 44_100, 48_000, 96_000];
|
||||
|
||||
/// Open the AAF or OMF settings dialog (`id` is the menu command).
|
||||
pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Result<Value, String> {
|
||||
let omf = id == "file.exportOmf";
|
||||
let seq = app.session.state.active_sequence.and_then(|s| app.session.project.item(s));
|
||||
let title = seq.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let sr = app.session.active_sequence().map(|q| q.settings.sample_rate).filter(|r| RATES.contains(r)).unwrap_or(48_000);
|
||||
let d = Draft {
|
||||
omf,
|
||||
title,
|
||||
mixdown_video: false,
|
||||
breakout: false,
|
||||
audio: "embedded",
|
||||
format: "wav",
|
||||
sample_rate: sr,
|
||||
bits: 16,
|
||||
trim: omf,
|
||||
handles: if omf { 30 } else { 0 },
|
||||
render_effects: false,
|
||||
};
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), Some(d)));
|
||||
Ok(json!({"dialog": if omf { "omfExport" } else { "aafExport" }}))
|
||||
}
|
||||
|
||||
/// Whether the dialog is open.
|
||||
pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())).flatten().is_some()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
|
||||
let p = if d.omf { "omfExport" } else { "aafExport" };
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" };
|
||||
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
|
||||
ui.set_min_width(380.0);
|
||||
egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
if d.omf {
|
||||
ui.label("OMF Title:");
|
||||
let r = ui.text_edit_singleline(&mut d.title);
|
||||
elems.push((format!("{p}.title"), r.rect, d.title.clone()));
|
||||
ui.end_row();
|
||||
} else {
|
||||
ui.label("");
|
||||
let r = ui.checkbox(&mut d.mixdown_video, "Mixdown video");
|
||||
elems.push((format!("{p}.mixdownVideo"), r.rect, d.mixdown_video.to_string()));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Audio:");
|
||||
ui.horizontal(|ui| {
|
||||
let modes: &[(&'static str, &str)] = if d.omf {
|
||||
&[("embedded", "Encapsulate"), ("separate", "Separate Audio")]
|
||||
} else {
|
||||
&[("embedded", "Embed"), ("separate", "Separate Files"), ("linked", "Link to Media")]
|
||||
};
|
||||
for (k, label) in modes {
|
||||
let r = ui.radio(d.audio == *k, *label);
|
||||
if r.clicked() {
|
||||
d.audio = k;
|
||||
}
|
||||
elems.push((format!("{p}.audio.{k}"), r.rect, label.to_string()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Audio File Format:");
|
||||
ui.horizontal(|ui| {
|
||||
let formats: &[(&'static str, &str)] =
|
||||
if d.omf { &[("wav", "Broadcast Wave"), ("aiff", "AIFF")] } else { &[("wav", "Broadcast Wave"), ("aiff", "AIFF"), ("mxf", "OP-Atom MXF")] };
|
||||
for &(k, label) in formats {
|
||||
let r = ui.add_enabled(d.audio == "separate", egui::RadioButton::new(d.format == k, label));
|
||||
if r.clicked() {
|
||||
d.format = k;
|
||||
}
|
||||
elems.push((format!("{p}.audioFormat.{k}"), r.rect, label.to_string()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Sample Rate:");
|
||||
ui.horizontal(|ui| {
|
||||
for r in RATES {
|
||||
let b = ui.radio(d.sample_rate == r, format!("{r}"));
|
||||
if b.clicked() {
|
||||
d.sample_rate = r;
|
||||
}
|
||||
elems.push((format!("{p}.sampleRate.{r}"), b.rect, format!("{r} Hz")));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Bits per Sample:");
|
||||
ui.horizontal(|ui| {
|
||||
for b in [16u16, 24] {
|
||||
let r = ui.radio(d.bits == b, format!("{b}"));
|
||||
if r.clicked() {
|
||||
d.bits = b;
|
||||
}
|
||||
elems.push((format!("{p}.bitDepth.{b}"), r.rect, format!("{b}-bit")));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Rendering:");
|
||||
let linked = d.audio == "linked";
|
||||
let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, "Trim audio files"));
|
||||
elems.push((format!("{p}.trimAudio"), r.rect, d.trim.to_string()));
|
||||
ui.end_row();
|
||||
ui.label("Handle Frames:");
|
||||
let r = ui.add_enabled(d.trim && !linked, egui::DragValue::new(&mut d.handles).range(0..=10_000));
|
||||
elems.push((format!("{p}.handles"), r.rect, d.handles.to_string()));
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, "Render audio clip effects"));
|
||||
elems.push((format!("{p}.renderAudioEffects"), r.rect, d.render_effects.to_string()));
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let r = ui.checkbox(&mut d.breakout, "Breakout to mono");
|
||||
elems.push((format!("{p}.breakoutToMono"), r.rect, d.breakout.to_string()));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let c = ui.button("Cancel");
|
||||
elems.push((format!("{p}.cancel"), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
close = true;
|
||||
}
|
||||
let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent));
|
||||
elems.push((format!("{p}.ok"), o.rect, "OK".into()));
|
||||
if o.clicked() {
|
||||
apply = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
if apply {
|
||||
close = true;
|
||||
let (ext, filter) = if d.omf { ("omf", "OMF") } else { ("aaf", "AAF") };
|
||||
let stem = if d.title.is_empty() { "Sequence".to_string() } else { d.title.replace(['/', '\\', ':'], "_") };
|
||||
let suggested = format!("{stem}.{ext}");
|
||||
match app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &suggested)) {
|
||||
Some(path) => {
|
||||
let mut params = json!({
|
||||
"path": path,
|
||||
"breakoutToMono": d.breakout,
|
||||
"audio": d.audio,
|
||||
"audioFormat": d.format,
|
||||
"sampleRate": d.sample_rate,
|
||||
"bitDepth": d.bits,
|
||||
"trimAudio": d.trim && d.audio != "linked",
|
||||
"handles": d.handles,
|
||||
"renderAudioEffects": d.render_effects && d.audio != "linked",
|
||||
});
|
||||
if d.omf {
|
||||
params["title"] = json!(d.title);
|
||||
} else {
|
||||
params["mixdownVideo"] = json!(d.mixdown_video);
|
||||
}
|
||||
let cmd = if d.omf { "file.exportOmf" } else { "file.exportAaf" };
|
||||
match app.session.execute(cmd, params) {
|
||||
Ok(v) => app.ui.status = format!("Exported {}", v["path"].as_str().unwrap_or_default()),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
None if app.hooks.pick_save_as.is_none() => app.ui.status = "no save dialog available: run the command with a `path`".into(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) }));
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ pub mod file_dialogs;
|
|||
pub mod graphics;
|
||||
pub mod graphics_templates;
|
||||
pub mod import_mode;
|
||||
pub mod interchange_export;
|
||||
pub mod keyboard;
|
||||
pub mod lumetri;
|
||||
pub mod masks;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
//! Headless UI tests of File ▸ Export ▸ AAF… / OMF…: the settings dialogs, driven by automation id
|
||||
//! through the control channel, export through the host's save panel.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(session: Session, save_to: std::path::PathBuf) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||||
app.hooks.pick_save_as = Some(Box::new(move |_, exts, suggested| {
|
||||
assert!(suggested.ends_with(&format!(".{}", exts[0])), "{suggested}");
|
||||
Some(save_to.join(suggested).to_string_lossy().into_owned())
|
||||
}));
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
||||
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A sequence "Mix" with one second of a 440 Hz tone on A1.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap();
|
||||
let inter: Vec<f32> = (0..48_000)
|
||||
.flat_map(|i| {
|
||||
let x = (i as f32 * 440.0 * std::f32::consts::TAU / 48_000.0).sin() * 0.5;
|
||||
[x, x]
|
||||
})
|
||||
.collect();
|
||||
let bytes: Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into();
|
||||
let item = filmcraft_engine::commands::import_bytes(&mut s, "/tone.wav", bytes, None).unwrap();
|
||||
s.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fc-ix-ui-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_and_omf_export_dialogs() {
|
||||
let dir = tmp("dialogs");
|
||||
let mut d = Driver::new(session(), dir.clone());
|
||||
let items = d.ok("ui.menu.list", json!({}));
|
||||
let menu: Vec<(String, Vec<String>)> = items
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| (i["id"].as_str().unwrap().to_string(), i["path"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect()))
|
||||
.collect();
|
||||
for id in ["file.exportAaf", "file.exportOmf"] {
|
||||
let (_, path) = menu.iter().find(|(i, _)| i == id).unwrap_or_else(|| panic!("{id} missing from the menus"));
|
||||
assert_eq!(path, &vec!["File".to_string(), "Export".to_string()]);
|
||||
}
|
||||
|
||||
// AAF: the dialog lists Premiere's options; OK asks for the file and exports
|
||||
let r = d.ok("ui.menu.invoke", json!({"id": "file.exportAaf"}));
|
||||
assert_eq!(r["dialog"], "aafExport");
|
||||
d.frames(3);
|
||||
let ids = d.ids("aafExport.");
|
||||
for id in [
|
||||
"mixdownVideo",
|
||||
"breakoutToMono",
|
||||
"audio.embedded",
|
||||
"audio.separate",
|
||||
"audio.linked",
|
||||
"audioFormat.wav",
|
||||
"sampleRate.48000",
|
||||
"bitDepth.24",
|
||||
"trimAudio",
|
||||
"handles",
|
||||
"renderAudioEffects",
|
||||
"ok",
|
||||
"cancel",
|
||||
] {
|
||||
assert!(ids.contains(&format!("aafExport.{id}")), "aafExport.{id} missing: {ids:?}");
|
||||
}
|
||||
d.ok("ui.click", json!({"id": "aafExport.bitDepth.24"}));
|
||||
d.ok("ui.click", json!({"id": "aafExport.breakoutToMono"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.click", json!({"id": "aafExport.ok"}));
|
||||
d.frames(4);
|
||||
assert!(d.ids("aafExport.").is_empty(), "the dialog closes");
|
||||
let aaf = std::fs::read(dir.join("Mix.aaf")).expect("Mix.aaf written");
|
||||
assert!(filmcraft_interchange::aaf::sniff(&aaf));
|
||||
let (imp, extracted, _) = filmcraft_interchange::aaf::import(&aaf, &Default::default()).unwrap();
|
||||
assert_eq!(extracted.len(), 2, "embedded, broken out to two mono essences");
|
||||
let (_, ch, sr, bits) = filmcraft_interchange::wav::parse_wav(&extracted[0].wav).unwrap();
|
||||
assert_eq!((ch, sr, bits), (1, 48_000, 24));
|
||||
assert_eq!(imp.sequences.len(), 1);
|
||||
|
||||
// OMF: cancel leaves nothing behind; OK with separate AIFF files
|
||||
d.ok("ui.menu.invoke", json!({"id": "file.exportOmf"}));
|
||||
d.frames(3);
|
||||
let ids = d.ids("omfExport.");
|
||||
assert!(ids.contains(&"omfExport.title".to_string()) && !ids.contains(&"omfExport.audio.linked".to_string()), "{ids:?}");
|
||||
d.ok("ui.click", json!({"id": "omfExport.cancel"}));
|
||||
d.frames(3);
|
||||
assert!(!dir.join("Mix.omf").exists());
|
||||
d.ok("ui.menu.invoke", json!({"id": "file.exportOmf"}));
|
||||
d.frames(3);
|
||||
d.ok("ui.click", json!({"id": "omfExport.audio.separate"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.click", json!({"id": "omfExport.audioFormat.aiff"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.click", json!({"id": "omfExport.ok"}));
|
||||
d.frames(4);
|
||||
let omf = std::fs::read(dir.join("Mix.omf")).expect("Mix.omf written");
|
||||
assert!(filmcraft_interchange::omf::sniff(&omf));
|
||||
let audio_dir = dir.join("Mix Audio Files");
|
||||
let aifs: Vec<_> = std::fs::read_dir(&audio_dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().extension().is_some_and(|x| x == "aif")).collect();
|
||||
assert_eq!(aifs.len(), 1, "one trimmed AIFF");
|
||||
|
||||
// with a path the menu command exports directly (agents)
|
||||
let p = dir.join("direct.aaf").to_string_lossy().into_owned();
|
||||
let v = d.ok("ui.menu.invoke", json!({"id": "file.exportAaf", "params": {"path": p, "audio": "linked"}}));
|
||||
assert!(v.get("dialog").is_none(), "{v}");
|
||||
assert!(std::path::Path::new(&p).exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ Design principles:
|
|||
L2 edit · codecs · interchange · captions · speech
|
||||
L1 frame · media · project · audio-dsp · text
|
||||
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
|
||||
codecs/containers: isobmff · matroska · mxf · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
|
||||
codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · aac · ac3 · opus
|
||||
```
|
||||
|
||||
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
|
||||
|
|
@ -41,7 +41,8 @@ and `filmcraft-cli`.
|
|||
| `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention |
|
||||
| `isobmff` | L0 | MP4/MOV demux and mux |
|
||||
| `matroska` | L0 | MKV/WebM demux |
|
||||
| `mxf` | L0 | MXF demux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode ([README](../crates/mxf/README.md)) |
|
||||
| `mxf` | L0 | MXF demux and mux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode; writer for OP1a / OP-Atom with VC-3, ProRes, AVC and PCM ([README](../crates/mxf/README.md)) |
|
||||
| `cfb` | L0 | Compound File Binary ([MS-CFB] structured storage) reader and writer, the container of AAF ([README](../crates/cfb/README.md)) |
|
||||
| `mpegts` | L0 | MPEG-2 Systems demux (H.222.0): transport streams (188 / 192-byte BDAV / 204), program streams (MPEG-2 / MPEG-1, VOB, MOD), PSI, PES, PTS / PCR, access-unit index built on open ([README](../crates/mpegts/README.md)) |
|
||||
| `ogg` | L0 | Ogg demux (RFC 3533), Ogg Opus timing (RFC 7845 granules, pre-skip, end trimming), Vorbis headers |
|
||||
| `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder |
|
||||
|
|
@ -61,7 +62,7 @@ and `filmcraft-cli`.
|
|||
| `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…; text-based editing: `edit::transcript`) |
|
||||
| `speech` | L2 | speech-to-text: `Transcriber` trait, Whisper model catalogue + verified downloader (feature `download`), pure-Rust Whisper inference on candle with word timestamps (feature `whisper`), speaker labelling ([transcripts.md](transcripts.md)) |
|
||||
| `codecs` | L2 | container + codec hub: MP4/MOV, MKV, MXF, Ogg and MPEG TS / PS / video elementary stream sources, GOP-aware seeking, decoder registry, audio decoding |
|
||||
| `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) |
|
||||
| `interchange` | L2 | EDL, FCP7 XML, FCPXML, OTIO, AAF (on `cfb`) and OMF 2.0 import/export (no file I/O; the engine supplies rendered audio essence) ([README](../crates/interchange/README.md)) |
|
||||
| `render` | L3 | sequence evaluation, CPU compositor, video effects (`effects`, `vfx`; effects needing other frames or tracks read them through `vfx::FxEnv`), transitions, audio mix |
|
||||
| `gpu` | L3 | wgpu compositor (WGSL) |
|
||||
| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only |
|
||||
|
|
@ -559,6 +560,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
|||
| `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV |
|
||||
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
|
||||
| `mjpeg` | built in | MOV |
|
||||
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
|
||||
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
|
||||
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
|
||||
|
||||
|
|
@ -571,7 +574,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
|||
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.
|
||||
- **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive
|
||||
H.264 at 0.2 / 0.1 / 0.05 bits per pixel, 1080p / 2160p delivery, vertical 1080×1920, ProRes,
|
||||
DNxHR, image sequences, GIF, WAV / AIFF). User presets and favourites persist in
|
||||
DNxHR, MXF OP1a (DNxHR HQ / ProRes 422 HQ / H.264) and OP-Atom (DNxHR, Avid), image sequences, GIF, WAV / AIFF). User presets and favourites persist in
|
||||
`<data dir>/export-presets.json` (`export.presets.*`).
|
||||
- **Queue.** `export.queue.add` snapshots the project and the resolved settings (several sequences
|
||||
or ranges add several items); `export.queue.start` encodes ready items one after another as
|
||||
|
|
@ -582,9 +585,14 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
|||
Video encoders implement `export::VideoEncoder`. Codec crates plug in with `register_encoder` and
|
||||
`register_audio_encoder`.
|
||||
|
||||
Timelines can be exchanged as EDL, FCP7 XML, FCPXML or OTIO. `file.import` detects these formats and
|
||||
merges the result into the project as one undoable step, and `file.exportInterchange`,
|
||||
`file.exportEdl`, `file.exportFcpxml` and `file.exportOtio` write them.
|
||||
Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these
|
||||
formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is
|
||||
written next to the document first), and `file.exportInterchange`, `file.exportEdl`,
|
||||
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. For AAF and
|
||||
OMF the engine (`engine::aaf_omf`) first prepares the audio the document references: it lists the
|
||||
used ranges (`interchange::essence::audio_needs`), decodes or renders them (clip effects through the
|
||||
export audio pipeline), embeds them or writes WAV / AIFF files, and optionally renders a video
|
||||
mixdown.
|
||||
|
||||
## 7. Automation surfaces
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const LAYERS: &[(&str, u8)] = &[
|
|||
("isobmff", 0),
|
||||
("matroska", 0),
|
||||
("mxf", 0),
|
||||
("cfb", 0),
|
||||
("mpegts", 0),
|
||||
("mpeg2v", 0),
|
||||
("ac3", 0),
|
||||
|
|
|
|||
Loading…
Reference in New Issue